From 56b95deee03cb834eddc1ba827a0419e3be5519e Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Sat, 3 Oct 2026 12:40:26 -0400 Subject: [PATCH] feat(wfctl): add bounded automation records Automation must not treat stopped errors or leaked stdout as success. Supervise pipeline actors, broker strict sandboxes, and retain recovery state until exact cleanup is proven. Bind registry changes to reread immutable release identity and canonical Git tree reports. --- cmd/wfctl/local_external_plugins.go | 49 + cmd/wfctl/main.go | 3 + cmd/wfctl/main_test.go | 3 + cmd/wfctl/pipeline.go | 34 + cmd/wfctl/pipeline_broker_host_test.go | 50 + cmd/wfctl/pipeline_broker_unix.go | 226 ++++ cmd/wfctl/pipeline_broker_unix_test.go | 213 ++++ cmd/wfctl/pipeline_cleanup_unix.go | 281 +++++ cmd/wfctl/pipeline_cleanup_unix_test.go | 223 ++++ cmd/wfctl/pipeline_closure.go | 317 ++++++ cmd/wfctl/pipeline_closure_test.go | 250 ++++ cmd/wfctl/pipeline_docker_unix.go | 615 ++++++++++ cmd/wfctl/pipeline_docker_unix_test.go | 1011 +++++++++++++++++ cmd/wfctl/pipeline_process_darwin.go | 47 + cmd/wfctl/pipeline_process_darwin_test.go | 53 + cmd/wfctl/pipeline_process_linux.go | 73 ++ cmd/wfctl/pipeline_record.go | 253 +++++ cmd/wfctl/pipeline_record_child_unix.go | 141 +++ cmd/wfctl/pipeline_record_child_unix_test.go | 430 +++++++ cmd/wfctl/pipeline_record_execute.go | 107 ++ cmd/wfctl/pipeline_record_host_test.go | 963 ++++++++++++++++ cmd/wfctl/pipeline_record_live_test.go | 102 ++ cmd/wfctl/pipeline_record_other.go | 20 + cmd/wfctl/pipeline_record_test.go | 209 ++++ cmd/wfctl/pipeline_record_unix.go | 499 ++++++++ cmd/wfctl/pipeline_record_unix_test.go | 51 + cmd/wfctl/pipeline_test.go | 32 + cmd/wfctl/plugin_registry_sync.go | 68 +- ...lugin_registry_sync_host_negatives_test.go | 360 ++++++ cmd/wfctl/plugin_registry_sync_host_test.go | 178 +++ cmd/wfctl/plugin_registry_sync_proxy_test.go | 250 ++++ cmd/wfctl/plugin_registry_sync_report.go | 441 +++++++ cmd/wfctl/plugin_registry_sync_report_test.go | 497 ++++++++ cmd/wfctl/plugin_registry_sync_target.go | 370 ++++++ cmd/wfctl/plugin_registry_sync_target_test.go | 293 +++++ cmd/wfctl/testdata/pipeline-record/README.md | 98 ++ .../testdata/pipeline-record/branch.yaml | 28 + .../testdata/pipeline-record/builtin.yaml | 10 + .../testdata/pipeline-record/cleanup.yaml | 35 + .../testdata/pipeline-record/cli-print.yaml | 13 + .../pipeline-record/compensation.yaml | 52 + .../testdata/pipeline-record/docker/main.go | 63 + .../pipeline-record/docker/transport.go | 216 ++++ .../pipeline-record/docker/transport_test.go | 49 + .../testdata/pipeline-record/external.yaml | 15 + .../testdata/pipeline-record/foreach.yaml | 35 + .../testdata/pipeline-record/late-stdout.yaml | 22 + .../pipeline-record/missing-result.yaml | 14 + .../testdata/pipeline-record/parallel.yaml | 43 + .../testdata/pipeline-record/plugin/main.go | 296 +++++ .../pipeline-record/plugin/main_test.go | 353 ++++++ .../pipeline-record/plugin/plugin.json | 7 + .../resilient-circuit-breaker.yaml | 53 + .../pipeline-record/retry-with-backoff.yaml | 36 + .../testdata/pipeline-record/sandbox.yaml | 20 + cmd/wfctl/testdata/pipeline-record/while.yaml | 37 + .../pipeline-record/workflow-call.yaml | 29 + cmd/wfctl/testdata/registry-sync/README.md | 65 ++ cmd/wfctl/type_registry.go | 9 +- cmd/wfctl/type_registry_test.go | 11 + docs/WFCTL.md | 114 +- .../registry-sync-operation-v1.schema.json | 75 ++ .../wfctl-pipeline-record-v1.schema.json | 16 + module/execenv_factory.go | 2 +- module/http_trigger.go | 58 +- module/http_trigger_host_test.go | 137 +++ module/http_trigger_test.go | 91 ++ module/pipeline_step_container_build_test.go | 40 +- module/pipeline_step_sandbox_exec.go | 124 +- module/pipeline_step_sandbox_exec_test.go | 100 +- module/pipeline_step_webhook_verify.go | 61 +- module/pipeline_step_webhook_verify_test.go | 149 +++ plugin/external/manager.go | 18 +- plugin/external/remote_step.go | 17 +- plugin/external/remote_step_test.go | 105 +- plugins/http/plugin.go | 3 + sandbox/docker.go | 40 +- sandbox/docker_test.go | 44 +- sandbox/filesystem.go | 64 ++ sandbox/record_policy.go | 70 ++ sandbox/record_policy_test.go | 63 + sandbox/runner.go | 16 + 82 files changed, 11663 insertions(+), 65 deletions(-) create mode 100644 cmd/wfctl/pipeline_broker_host_test.go create mode 100644 cmd/wfctl/pipeline_broker_unix.go create mode 100644 cmd/wfctl/pipeline_broker_unix_test.go create mode 100644 cmd/wfctl/pipeline_cleanup_unix.go create mode 100644 cmd/wfctl/pipeline_cleanup_unix_test.go create mode 100644 cmd/wfctl/pipeline_closure.go create mode 100644 cmd/wfctl/pipeline_closure_test.go create mode 100644 cmd/wfctl/pipeline_docker_unix.go create mode 100644 cmd/wfctl/pipeline_docker_unix_test.go create mode 100644 cmd/wfctl/pipeline_process_darwin.go create mode 100644 cmd/wfctl/pipeline_process_darwin_test.go create mode 100644 cmd/wfctl/pipeline_process_linux.go create mode 100644 cmd/wfctl/pipeline_record.go create mode 100644 cmd/wfctl/pipeline_record_child_unix.go create mode 100644 cmd/wfctl/pipeline_record_child_unix_test.go create mode 100644 cmd/wfctl/pipeline_record_execute.go create mode 100644 cmd/wfctl/pipeline_record_host_test.go create mode 100644 cmd/wfctl/pipeline_record_live_test.go create mode 100644 cmd/wfctl/pipeline_record_other.go create mode 100644 cmd/wfctl/pipeline_record_test.go create mode 100644 cmd/wfctl/pipeline_record_unix.go create mode 100644 cmd/wfctl/pipeline_record_unix_test.go create mode 100644 cmd/wfctl/plugin_registry_sync_host_negatives_test.go create mode 100644 cmd/wfctl/plugin_registry_sync_host_test.go create mode 100644 cmd/wfctl/plugin_registry_sync_proxy_test.go create mode 100644 cmd/wfctl/plugin_registry_sync_report.go create mode 100644 cmd/wfctl/plugin_registry_sync_report_test.go create mode 100644 cmd/wfctl/plugin_registry_sync_target.go create mode 100644 cmd/wfctl/plugin_registry_sync_target_test.go create mode 100644 cmd/wfctl/testdata/pipeline-record/README.md create mode 100644 cmd/wfctl/testdata/pipeline-record/branch.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/builtin.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/cleanup.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/cli-print.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/compensation.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/docker/main.go create mode 100644 cmd/wfctl/testdata/pipeline-record/docker/transport.go create mode 100644 cmd/wfctl/testdata/pipeline-record/docker/transport_test.go create mode 100644 cmd/wfctl/testdata/pipeline-record/external.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/foreach.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/late-stdout.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/missing-result.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/parallel.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/plugin/main.go create mode 100644 cmd/wfctl/testdata/pipeline-record/plugin/main_test.go create mode 100644 cmd/wfctl/testdata/pipeline-record/plugin/plugin.json create mode 100644 cmd/wfctl/testdata/pipeline-record/resilient-circuit-breaker.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/retry-with-backoff.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/sandbox.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/while.yaml create mode 100644 cmd/wfctl/testdata/pipeline-record/workflow-call.yaml create mode 100644 cmd/wfctl/testdata/registry-sync/README.md create mode 100644 docs/schemas/registry-sync-operation-v1.schema.json create mode 100644 docs/schemas/wfctl-pipeline-record-v1.schema.json create mode 100644 module/http_trigger_host_test.go create mode 100644 sandbox/filesystem.go create mode 100644 sandbox/record_policy.go create mode 100644 sandbox/record_policy_test.go diff --git a/cmd/wfctl/local_external_plugins.go b/cmd/wfctl/local_external_plugins.go index 10dfe4b7..79c60483 100644 --- a/cmd/wfctl/local_external_plugins.go +++ b/cmd/wfctl/local_external_plugins.go @@ -5,10 +5,12 @@ import ( "io" "log" "log/slog" + "os" "sort" "strings" "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/workflow/plugin" pluginexternal "github.com/GoCodeAlone/workflow/plugin/external" ) @@ -56,6 +58,53 @@ func newLocalExternalPluginStdLogger(logger *slog.Logger) *log.Logger { return log.New(localExternalPluginLogWriter{logger: logger}, "", 0) } +func loadPipelineExternalPlugins(eng *workflow.StdEngine, pluginDir string, names []string, logger *slog.Logger) (func(), error) { + installed, err := inspectPipelineInstallations(pluginDir) + if err != nil { + return nil, err + } + manager := pluginexternal.NewExternalPluginManager(pluginDir, newLocalExternalPluginStdLogger(logger)) + manager.SetProcessOutput(os.Stdout, os.Stderr) + for _, name := range names { + adapter, err := manager.LoadPlugin(name) + if err != nil { + manager.Shutdown() + return nil, fmt.Errorf("load selected external plugin %q: %w", name, err) + } + if adapter.EngineManifest().Name != name { + manager.Shutdown() + return nil, fmt.Errorf("external plugin runtime installation identity mismatch") + } + if len(adapter.ConfigFragmentBytes()) != 0 { + manager.Shutdown() + return nil, fmt.Errorf("record mode rejects external config fragments") + } + factories := adapter.StepFactories() + if err := validatePipelineRuntimeTypes(installed.manifests[name], factories); err != nil { + manager.Shutdown() + return nil, err + } + if err := eng.LoadPlugin(adapter); err != nil { + manager.Shutdown() + return nil, fmt.Errorf("register selected external plugin: %w", err) + } + registerPipelineFailureFactories(eng, factories) + } + return manager.Shutdown, nil +} + +func validatePipelineRuntimeTypes(manifest *plugin.PluginManifest, factories map[string]plugin.StepFactory) error { + if manifest == nil || len(factories) != len(manifest.StepTypes) { + return fmt.Errorf("external runtime step ownership differs from installed manifest") + } + for _, name := range manifest.StepTypes { + if _, exists := factories[name]; !exists { + return fmt.Errorf("external runtime step ownership differs from installed manifest") + } + } + return nil +} + type localExternalPluginLogWriter struct { logger *slog.Logger } diff --git a/cmd/wfctl/main.go b/cmd/wfctl/main.go index 51928797..1ec862eb 100644 --- a/cmd/wfctl/main.go +++ b/cmd/wfctl/main.go @@ -164,6 +164,9 @@ var commands = map[string]func([]string) error{ } func main() { + if handled, code := pipelineRecordEarlyDispatch(os.Args[1:]); handled { + os.Exit(code) + } // Load the embedded config. All command definitions and pipeline wiring // live in wfctl.yaml — no hardcoded routing in this file. cfg, err := config.LoadFromBytes(wfctlConfigBytes) diff --git a/cmd/wfctl/main_test.go b/cmd/wfctl/main_test.go index e106709c..ae981fb6 100644 --- a/cmd/wfctl/main_test.go +++ b/cmd/wfctl/main_test.go @@ -30,6 +30,9 @@ import ( // are exercised by platform/differ_cache_test.go via its own // in-memory swap. func TestMain(m *testing.M) { + if len(os.Args) == 2 && os.Args[1] == pipelineRecordChildCommand { + os.Exit(runPipelineRecordChild()) + } if err := os.Setenv("WFCTL_DIFFCACHE", "disabled"); err != nil { panic("setenv WFCTL_DIFFCACHE: " + err.Error()) } diff --git a/cmd/wfctl/pipeline.go b/cmd/wfctl/pipeline.go index 74e36202..bfb8bba3 100644 --- a/cmd/wfctl/pipeline.go +++ b/cmd/wfctl/pipeline.go @@ -113,6 +113,10 @@ func runPipelineRun(args []string) error { pluginDir := fs.String("plugin-dir", "", "Directory containing installed external plugins") inputJSON := fs.String("input", "", "Input data as JSON object") verbose := fs.Bool("verbose", false, "Show detailed step output") + outputMode := fs.String("output", "human", "Output mode: human or record") + resultStep := fs.String("result-step", "", "Literal reachable result step for record output") + recordPrefix := fs.String("record-prefix", "", "Success record prefix") + errorPrefix := fs.String("error-prefix", "", "Error record prefix") var vars stringSliceFlag fs.Var(&vars, "var", "Variable in key=value format (repeatable)") fs.Usage = func() { @@ -144,6 +148,16 @@ Options: if *pluginDir == "" { *pluginDir = strings.TrimSpace(os.Getenv("WFCTL_PLUGIN_DIR")) } + if *outputMode != "human" && *outputMode != "record" { + return fmt.Errorf("--output must be human or record") + } + if *outputMode == "record" { + if err := validatePipelineRecordSelectors(*resultStep, *recordPrefix, *errorPrefix); err != nil { + return err + } + } else if *resultStep != "" || *recordPrefix != "" || *errorPrefix != "" { + return fmt.Errorf("record selectors require --output record") + } // Build initial trigger data from --input JSON triggerData := make(map[string]any) @@ -151,6 +165,9 @@ Options: if err := json.Unmarshal([]byte(*inputJSON), &triggerData); err != nil { return fmt.Errorf("invalid --input JSON: %w", err) } + if triggerData == nil { + return fmt.Errorf("invalid --input JSON: expected an object") + } } // Inject --var entries into trigger data @@ -161,6 +178,11 @@ Options: } triggerData[kv[:idx]] = kv[idx+1:] } + if *outputMode == "record" { + return runPipelineRecord(pipelineRecordRequest{Pipeline: *pipelineName, ResultStep: *resultStep, + PluginDir: *pluginDir, Input: triggerData}, pipelineRecordOptions{ConfigPath: *configPath, + SuccessPrefix: *recordPrefix, ErrorPrefix: *errorPrefix}) + } // Load config cfg, err := config.LoadFromFile(*configPath) @@ -182,6 +204,15 @@ Options: } // Set up a logger — suppress engine noise unless --verbose + closure, err := selectPipelineClosure(cfg, *pipelineName, false) + if err != nil { + return err + } + if _, err := inspectPipelineInstallations(*pluginDir); err != nil { + return err + } + cfg = closure.config + logLevel := slog.LevelError if *verbose { logLevel = slog.LevelDebug @@ -289,6 +320,9 @@ func (ps *progressStep) Execute(ctx context.Context, pc *module.PipelineContext) fmt.Printf("Step %d/%d: %s ... ", ps.index+1, ps.total, ps.inner.Name()) result, err := ps.inner.Execute(ctx, pc) + if err == nil { + err = stoppedPipelineError(result) + } elapsed := time.Since(start) if err != nil { diff --git a/cmd/wfctl/pipeline_broker_host_test.go b/cmd/wfctl/pipeline_broker_host_test.go new file mode 100644 index 00000000..bdd34e70 --- /dev/null +++ b/cmd/wfctl/pipeline_broker_host_test.go @@ -0,0 +1,50 @@ +//go:build linux || darwin + +package main + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func TestPipelineRecordHostSandboxBroker(t *testing.T) { + root := pipelineRecordHostTempDir(t) + binary := filepath.Join(root, "wfctl") + buildPipelineRecordHostBinary(t, binary, ".") + // This substitutes container transport only. The actual Workflow sandbox + // step, templates, WFD2 channel, journal, and parent Docker adapter execute. + dir, _ := newPipelineDockerFixture(t) + t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state")) + t.Setenv("GITHUB_TOKEN", "host-private-canary") + stdout, stderr, err := runPipelineRecordHost(t, binary, "testdata/pipeline-record/sandbox.yaml", "", "--var", "message=dynamic-message") + if err != nil || string(stdout) != "SDK_RECORD_V1 {\"message\":\"captured output\",\"source\":\"broker\"}\n" { + t.Fatalf("Workflow sandbox broker path failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + calls := pipelineDockerCalls(t, dir) + var created bool + for _, call := range calls { + if !slices.Contains(call.Args, "create") { + continue + } + created = true + args := strings.Join(call.Args, " ") + for _, value := range []string{"--user 65532:65532", "--read-only", "--cap-drop ALL", "--security-opt no-new-privileges", "--pids-limit 64", "--cpus 1", "PUBLIC_VALUE=dynamic-message", "printf '%s' 'dynamic-message'"} { + if !strings.Contains(args, value) { + t.Errorf("broker omitted strict or resolved argument %q: %q", value, args) + } + } + if strings.Contains(args, "host-private-canary") || strings.Contains(args, "GITHUB_TOKEN") { + t.Fatal("host token entered the container request") + } + } + if !created { + t.Fatal("sandbox transport never received create") + } + entries, err := os.ReadDir(filepath.Join(root, "state", "wfctl", "pipeline-cleanup")) + if err != nil || len(entries) != 1 || entries[0].Name() != "lock" { + t.Fatalf("completed sandbox left retained journal state: %v, %v", entries, err) + } +} diff --git a/cmd/wfctl/pipeline_broker_unix.go b/cmd/wfctl/pipeline_broker_unix.go new file mode 100644 index 00000000..eaa63c1c --- /dev/null +++ b/cmd/wfctl/pipeline_broker_unix.go @@ -0,0 +1,226 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "sync" + "time" + "unicode/utf8" + + "github.com/GoCodeAlone/workflow/sandbox" +) + +// JSON escaping can expand each output byte to six bytes (for example, \u0000). +const maxPipelineBrokerResponseBytes = 6*(2*sandbox.MaxOutputBytes) + 16384 + +type pipelineBrokerRequest struct { + Operation string `json:"operation"` + Config sandbox.SandboxConfig `json:"config"` + Command []string `json:"command"` + Deadline string `json:"deadline"` +} + +type pipelineBrokerResponse struct { + Result *sandbox.ExecResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func writePipelineBrokerFrame(writer io.Writer, value any, limit int) error { + data, err := json.Marshal(value) + if err != nil || len(data) == 0 || len(data) > limit { + return errPipelineRecordInvalid + } + frame := []byte("WFD2\x00\x00\x00\x00") + binary.BigEndian.PutUint32(frame[4:], uint32(len(data))) // #nosec G115 -- callers bound payloads to at most 12 MiB + 16 KiB. + frame = append(frame, data...) + n, err := writer.Write(frame) + if err == nil && n != len(frame) { + err = io.ErrShortWrite + } + return err +} + +func readPipelineBrokerFrame(reader io.Reader, value any, limit int) error { + var header [8]byte + if _, err := io.ReadFull(reader, header[:]); err != nil { + return err + } + length := binary.BigEndian.Uint32(header[4:]) + if string(header[:4]) != "WFD2" || length == 0 || uint64(length) > uint64(limit) { // #nosec G115 -- callers use positive, bounded protocol limits. + return errPipelineRecordInvalid + } + data := make([]byte, int(length)) + if _, err := io.ReadFull(reader, data); err != nil { + return err + } + if err := validatePipelinePrivateJSON(data); err != nil { + return err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return errPipelineRecordInvalid + } + if decoder.Decode(new(any)) != io.EOF { + return errPipelineRecordInvalid + } + return nil +} + +// Private requests allow typed numeric resource limits, but never duplicate +// object keys, invalid UTF-8, excessive nesting, or trailing JSON values. +func validatePipelinePrivateJSON(data []byte) error { + if !utf8.Valid(data) { + return errPipelineRecordInvalid + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var walk func(int) error + walk = func(depth int) error { + if depth > 64 { + return errPipelineRecordInvalid + } + token, err := decoder.Token() + if err != nil { + return errPipelineRecordInvalid + } + delim, composite := token.(json.Delim) + if !composite { + return nil + } + switch delim { + case '{': + seen := make(map[string]bool) + for decoder.More() { + key, err := decoder.Token() + name, ok := key.(string) + if err != nil || !ok || seen[name] { + return errPipelineRecordInvalid + } + seen[name] = true + if err := walk(depth + 1); err != nil { + return err + } + } + if close, err := decoder.Token(); err != nil || close != json.Delim('}') { + return errPipelineRecordInvalid + } + case '[': + for decoder.More() { + if err := walk(depth + 1); err != nil { + return err + } + } + if close, err := decoder.Token(); err != nil || close != json.Delim(']') { + return errPipelineRecordInvalid + } + default: + return errPipelineRecordInvalid + } + return nil + } + if err := walk(0); err != nil { + return err + } + if _, err := decoder.Token(); err != io.EOF { + return errPipelineRecordInvalid + } + return nil +} + +type pipelineBrokerClient struct { + conn net.Conn + mu sync.Mutex + failed bool +} + +func newPipelineBrokerClient(conn net.Conn) *pipelineBrokerClient { + return &pipelineBrokerClient{conn: conn} +} + +func (c *pipelineBrokerClient) Runner(cfg sandbox.SandboxConfig) (sandbox.SandboxRunner, error) { + return &pipelineBrokerRunner{client: c, config: cfg}, nil +} + +type pipelineBrokerRunner struct { + client *pipelineBrokerClient + config sandbox.SandboxConfig +} + +func (r *pipelineBrokerRunner) Close() error { return nil } + +func (r *pipelineBrokerRunner) Exec(ctx context.Context, command []string) (*sandbox.ExecResult, error) { + client := r.client + client.mu.Lock() + defer client.mu.Unlock() + if client.failed || ctx.Err() != nil { + return nil, errors.New("record sandbox broker unavailable") + } + timeout := r.config.Timeout + if timeout <= 0 || timeout > 10*time.Minute { + return nil, errors.New("invalid record sandbox timeout") + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + deadline, _ := ctx.Deadline() + if err := client.conn.SetDeadline(deadline); err != nil { + return nil, err + } + stop := context.AfterFunc(ctx, func() { _ = client.conn.SetDeadline(time.Now()) }) + defer stop() + request := pipelineBrokerRequest{Operation: "exec", Config: r.config, Command: command, Deadline: deadline.UTC().Format(time.RFC3339Nano)} + if err := writePipelineBrokerFrame(client.conn, request, maxPipelineRecordBytes); err != nil { + client.failed = true + return nil, errors.New("record sandbox broker request failed") + } + var response pipelineBrokerResponse + if err := readPipelineBrokerFrame(client.conn, &response, maxPipelineBrokerResponseBytes); err != nil { + client.failed = true + return nil, errors.New("record sandbox broker response failed") + } + if response.Error != "" || !validPipelineBrokerResult(response.Result) { + return nil, errors.New("record sandbox execution failed") + } + return response.Result, nil +} + +func validPipelineBrokerResult(result *sandbox.ExecResult) bool { + return result != nil && len(result.Stdout) <= sandbox.MaxOutputBytes && len(result.Stderr) <= sandbox.MaxOutputBytes && + utf8.ValidString(result.Stdout) && utf8.ValidString(result.Stderr) +} + +func servePipelineBroker(ctx context.Context, conn net.Conn, execute func(context.Context, pipelineBrokerRequest) (*sandbox.ExecResult, error)) error { + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + defer stop() + for { + var request pipelineBrokerRequest + if err := readPipelineBrokerFrame(conn, &request, maxPipelineRecordBytes); err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + deadline, err := time.Parse(time.RFC3339Nano, request.Deadline) + if err != nil || request.Operation != "exec" || len(request.Command) == 0 || !deadline.After(time.Now()) || time.Until(deadline) > 10*time.Minute { + return fmt.Errorf("invalid record sandbox request") + } + requestCtx, cancel := context.WithDeadline(ctx, deadline) + result, err := execute(requestCtx, request) + cancel() + response := pipelineBrokerResponse{Result: result} + if err != nil || !validPipelineBrokerResult(result) { + response = pipelineBrokerResponse{Error: "sandbox_failed"} + } + if err := writePipelineBrokerFrame(conn, response, maxPipelineBrokerResponseBytes); err != nil { + return err + } + } +} diff --git a/cmd/wfctl/pipeline_broker_unix_test.go b/cmd/wfctl/pipeline_broker_unix_test.go new file mode 100644 index 00000000..30c46a73 --- /dev/null +++ b/cmd/wfctl/pipeline_broker_unix_test.go @@ -0,0 +1,213 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "net" + "strings" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/sandbox" +) + +func TestPipelineBrokerFrameBoundsAndTypes(t *testing.T) { + for name, data := range map[string][]byte{ + "bad magic": []byte("NOPE\x00\x00\x00\x02{}"), + "oversize": []byte("WFD2\xff\xff\xff\xff"), + "truncated": []byte("WFD2\x00\x00\x00\x02{"), + "unknown": brokerTestFrame(`{"operation":"exec","foreign":true}`), + "duplicate": brokerTestFrame(`{"operation":"exec","operation":"cancel"}`), + "trailing": brokerTestFrame(`{"operation":"exec"} {}`), + } { + t.Run(name, func(t *testing.T) { + var request pipelineBrokerRequest + if err := readPipelineBrokerFrame(bytes.NewReader(data), &request, maxPipelineRecordBytes); err == nil { + t.Fatal("accepted ambiguous or unbounded sandbox request") + } + }) + } +} + +func TestPipelineBrokerRunnerCarriesResolvedConfig(t *testing.T) { + child, parent := net.Pipe() + defer child.Close() + defer parent.Close() + requestDone := make(chan pipelineBrokerRequest, 1) + go func() { + var request pipelineBrokerRequest + if readPipelineBrokerFrame(parent, &request, maxPipelineRecordBytes) == nil { + requestDone <- request + _ = writePipelineBrokerFrame(parent, pipelineBrokerResponse{Result: &sandbox.ExecResult{ExitCode: 0, Stdout: "resolved"}}, maxPipelineBrokerResponseBytes) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + broker := newPipelineBrokerClient(child) + cfg := sandbox.DefaultSecureSandboxConfig("alpine:3.21") + cfg.Env = map[string]string{"PUBLIC_VALUE": "selected"} + runner, err := broker.Runner(cfg) + if err != nil { + t.Fatal(err) + } + result, err := runner.Exec(ctx, []string{"sh", "-euc", "printf %s selected"}) + if err != nil || result == nil || result.Stdout != "resolved" { + t.Fatalf("broker execution failed: %#v, %v", result, err) + } + select { + case request := <-requestDone: + if request.Operation != "exec" || request.Config.Env["PUBLIC_VALUE"] != "selected" || strings.Join(request.Command, " ") != "sh -euc printf %s selected" || request.Deadline == "" { + t.Fatalf("broker did not carry the execution boundary: %#v", request) + } + case <-ctx.Done(): + t.Fatal("broker did not deliver its request") + } +} + +func TestPipelineBrokerEscapedOutputWithinRawCaps(t *testing.T) { + for _, tc := range []struct { + name string + stdout string + stderr string + }{ + {name: "html escaping", stdout: strings.Repeat("<", 400000)}, + {name: "control characters at both caps", stdout: strings.Repeat("\x00", sandbox.MaxOutputBytes), stderr: strings.Repeat("\x1f", sandbox.MaxOutputBytes)}, + {name: "plain text at both caps", stdout: strings.Repeat("x", sandbox.MaxOutputBytes), stderr: strings.Repeat("y", sandbox.MaxOutputBytes)}, + } { + t.Run(tc.name, func(t *testing.T) { + want := &sandbox.ExecResult{ExitCode: 0, Stdout: tc.stdout, Stderr: tc.stderr} + got, err, peerErr := brokerTestExec(t, func(ctx context.Context, conn net.Conn) error { + return servePipelineBroker(ctx, conn, func(context.Context, pipelineBrokerRequest) (*sandbox.ExecResult, error) { + return want, nil + }) + }) + if err != nil || peerErr != nil || got == nil { + t.Fatalf("valid bounded escaped output rejected: stdout=%d stderr=%d client=%v peer=%v", len(tc.stdout), len(tc.stderr), err, peerErr) + } + if *got != *want { + t.Fatalf("broker changed bounded output: stdout=%d stderr=%d", len(got.Stdout), len(got.Stderr)) + } + }) + } +} + +func TestPipelineBrokerRejectsInvalidRawOutputBeforeEncoding(t *testing.T) { + for _, tc := range []struct { + name string + result sandbox.ExecResult + }{ + {name: "stdout invalid UTF-8", result: sandbox.ExecResult{Stdout: "private-stdout\xff"}}, + {name: "stderr invalid UTF-8", result: sandbox.ExecResult{Stderr: "private-stderr\xc3"}}, + {name: "stdout over cap", result: sandbox.ExecResult{Stdout: strings.Repeat("x", sandbox.MaxOutputBytes+1)}}, + {name: "stderr over cap", result: sandbox.ExecResult{Stderr: strings.Repeat("y", sandbox.MaxOutputBytes+1)}}, + } { + t.Run(tc.name, func(t *testing.T) { + response, err, peerErr := brokerTestRawResponse(t, &tc.result) + if err != nil || peerErr != nil { + t.Fatalf("invalid raw output did not return a bounded error frame: client=%v peer=%v", err, peerErr) + } + if response.Result != nil || response.Error != "sandbox_failed" { + t.Fatal("broker accepted invalid raw output, including JSON replacement of invalid UTF-8") + } + }) + } +} + +func TestPipelineBrokerRejectsDecodedOutputOverRawCaps(t *testing.T) { + for _, stream := range []string{"stdout", "stderr"} { + t.Run(stream, func(t *testing.T) { + result := &sandbox.ExecResult{} + if stream == "stdout" { + result.Stdout = strings.Repeat("x", sandbox.MaxOutputBytes+1) + } else { + result.Stderr = strings.Repeat("y", sandbox.MaxOutputBytes+1) + } + got, err, peerErr := brokerTestExec(t, func(_ context.Context, conn net.Conn) error { + var request pipelineBrokerRequest + if err := readPipelineBrokerFrame(conn, &request, maxPipelineRecordBytes); err != nil { + return err + } + return writePipelineBrokerFrame(conn, pipelineBrokerResponse{Result: result}, maxPipelineBrokerResponseBytes) + }) + if peerErr != nil { + t.Fatalf("oversized response did not reach the decoded raw-size guard: %v", peerErr) + } + if err == nil || got != nil { + t.Fatal("broker accepted decoded output above the raw cap") + } + }) + } +} + +func brokerTestExec(t *testing.T, peer func(context.Context, net.Conn) error) (*sandbox.ExecResult, error, error) { + t.Helper() + var result *sandbox.ExecResult + err, peerErr := brokerTestChannel(t, peer, func(ctx context.Context, conn net.Conn) error { + cfg := sandbox.DefaultSecureSandboxConfig("alpine:3.21") + cfg.Timeout = 5 * time.Second + runner, err := newPipelineBrokerClient(conn).Runner(cfg) + if err != nil { + return err + } + result, err = runner.Exec(ctx, []string{"printf", "fixture"}) + return err + }) + return result, err, peerErr +} + +func brokerTestRawResponse(t *testing.T, result *sandbox.ExecResult) (pipelineBrokerResponse, error, error) { + t.Helper() + var response pipelineBrokerResponse + err, peerErr := brokerTestChannel(t, func(ctx context.Context, conn net.Conn) error { + return servePipelineBroker(ctx, conn, func(context.Context, pipelineBrokerRequest) (*sandbox.ExecResult, error) { + return result, nil + }) + }, func(ctx context.Context, conn net.Conn) error { + deadline, _ := ctx.Deadline() + request := pipelineBrokerRequest{Operation: "exec", Command: []string{"printf", "fixture"}, Deadline: deadline.UTC().Format(time.RFC3339Nano)} + if err := writePipelineBrokerFrame(conn, request, maxPipelineRecordBytes); err != nil { + return err + } + return readPipelineBrokerFrame(conn, &response, maxPipelineBrokerResponseBytes) + }) + return response, err, peerErr +} + +func brokerTestChannel(t *testing.T, peer, exchange func(context.Context, net.Conn) error) (error, error) { + t.Helper() + child, parent := net.Pipe() + defer child.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + peerDone := make(chan error, 1) + go func() { + defer parent.Close() + err := peer(ctx, parent) + if ctx.Err() != nil && (errors.Is(err, net.ErrClosed) || errors.Is(err, io.ErrClosedPipe)) { + err = nil + } + peerDone <- err + }() + _ = child.SetDeadline(time.Now().Add(5 * time.Second)) + err := exchange(ctx, child) + _ = child.Close() + cancel() + select { + case peerErr := <-peerDone: + return err, peerErr + case <-time.After(time.Second): + t.Fatal("broker test peer did not stop after cancellation and connection close") + return nil, nil + } +} + +func brokerTestFrame(payload string) []byte { + header := []byte("WFD2\x00\x00\x00\x00") + binary.BigEndian.PutUint32(header[4:], uint32(len(payload))) + return append(header, payload...) +} diff --git a/cmd/wfctl/pipeline_cleanup_unix.go b/cmd/wfctl/pipeline_cleanup_unix.go new file mode 100644 index 00000000..0f866970 --- /dev/null +++ b/cmd/wfctl/pipeline_cleanup_unix.go @@ -0,0 +1,281 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" +) + +type pipelineProcessIdentity struct { + PID int `json:"pid"` + PGID int `json:"pgid"` + Start string `json:"start"` +} + +type pipelineDockerIdentity struct { + Endpoint string `json:"endpoint"` + TLSHash string `json:"tls_hash"` + ServerID string `json:"server_id"` +} + +type pipelineCleanupEntry struct { + Version int `json:"version"` + Label string `json:"label"` + Parent pipelineProcessIdentity `json:"parent"` + Child pipelineProcessIdentity `json:"child"` + Docker pipelineDockerIdentity `json:"docker"` + Deadline string `json:"deadline"` + CIDFiles []string `json:"cidfiles"` + IDs []string `json:"ids"` +} + +type pipelineCleanupState struct { + root string + lock *os.File +} + +func openPipelineCleanupState(root string) (*pipelineCleanupState, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, err + } + if err := os.MkdirAll(root, 0700); err != nil { + return nil, err + } + // Validate every path component, not just the final directory. The journal + // cannot live through a symlink that could redirect restart reconciliation. + for path := root; ; path = filepath.Dir(path) { + info, err := os.Lstat(path) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("unsafe pipeline cleanup state path") + } + if path == root { + if err := validatePipelinePrivateFile(info, 0700, true); err != nil { + return nil, err + } + } + if filepath.Dir(path) == path { + break + } + } + fd, err := unix.Open(filepath.Join(root, "lock"), unix.O_RDWR|unix.O_CREAT|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600) + if err != nil { + return nil, fmt.Errorf("unsafe pipeline cleanup lock: %w", err) + } + lock := os.NewFile(uintptr(fd), "pipeline cleanup lock") // #nosec G115 -- successful unix.Open returns a nonnegative descriptor. + info, err := lock.Stat() + if err == nil { + err = validatePipelinePrivateFile(info, 0600, false) + } + if err == nil { + err = unix.Flock(fd, unix.LOCK_EX|unix.LOCK_NB) + } + if err != nil { + _ = lock.Close() + return nil, fmt.Errorf("pipeline cleanup lock unavailable: %w", err) + } + return &pipelineCleanupState{root: root, lock: lock}, nil +} + +func validatePipelinePrivateFile(info os.FileInfo, mode os.FileMode, directory bool) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || int64(stat.Uid) != int64(os.Geteuid()) || info.Mode().Perm() != mode || + info.IsDir() != directory || (!directory && !info.Mode().IsRegular()) { + return fmt.Errorf("unsafe pipeline cleanup owner or mode") + } + return nil +} + +func (s *pipelineCleanupState) Close() error { return s.lock.Close() } + +func validPipelineCleanupLabel(label string) bool { + if !strings.HasPrefix(label, "wfctl-") || len(label) != len("wfctl-")+32 { + return false + } + data, err := hex.DecodeString(strings.TrimPrefix(label, "wfctl-")) + return err == nil && len(data) == 16 && strings.ToLower(label) == label +} + +func validatePipelineCleanupEntry(entry pipelineCleanupEntry) error { + if entry.Version != 1 || !validPipelineCleanupLabel(entry.Label) || entry.Parent.PID <= 0 || entry.Parent.Start == "" || + entry.Docker.Endpoint == "" || entry.Docker.TLSHash == "" || entry.Docker.ServerID == "" { + return fmt.Errorf("invalid pipeline cleanup identity") + } + if _, err := time.Parse(time.RFC3339Nano, entry.Deadline); err != nil { + return fmt.Errorf("invalid cleanup deadline") + } + if entry.Child.PID != 0 && (entry.Child.PID <= 0 || entry.Child.PGID != entry.Child.PID || entry.Child.Start == "") { + return fmt.Errorf("invalid child process identity") + } + if entry.Child.PID == 0 && (entry.Child.PGID != 0 || entry.Child.Start != "") { + return fmt.Errorf("partial child identity") + } + seen := make(map[string]bool) + for _, id := range entry.IDs { + if len(id) != 64 || strings.ToLower(id) != id { + return fmt.Errorf("invalid journaled container ID") + } + if _, err := hex.DecodeString(id); err != nil || seen[id] { + return fmt.Errorf("invalid journaled container ID") + } + seen[id] = true + } + for _, name := range entry.CIDFiles { + if !strings.HasPrefix(name, entry.Label+"-") || filepath.Base(name) != name || !strings.HasSuffix(name, ".cid") { + return fmt.Errorf("invalid journaled cidfile") + } + } + return nil +} + +func (s *pipelineCleanupState) Save(entry pipelineCleanupEntry) error { + if err := validatePipelineCleanupEntry(entry); err != nil { + return err + } + data, err := json.Marshal(entry) + if err != nil || len(data) > maxPipelineRecordBytes { + return fmt.Errorf("invalid cleanup record") + } + path := filepath.Join(s.root, entry.Label+".json") + if info, err := os.Lstat(path); err == nil { + if err := validatePipelinePrivateFile(info, 0600, false); err != nil { + return err + } + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + temp, err := os.CreateTemp(s.root, ".pending-") + if err != nil { + return err + } + defer os.Remove(temp.Name()) + 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 + } + if err := os.Rename(temp.Name(), path); err != nil { + return err + } + return s.sync() +} + +func (s *pipelineCleanupState) Entries() ([]pipelineCleanupEntry, error) { + names, err := os.ReadDir(s.root) + if err != nil { + return nil, err + } + var records []pipelineCleanupEntry + var cidFiles []string + allowedCID := make(map[string]bool) + for _, name := range names { + if name.Name() == "lock" { + continue + } + if strings.HasSuffix(name.Name(), ".cid") { + cidFiles = append(cidFiles, name.Name()) + continue + } + if !strings.HasSuffix(name.Name(), ".json") { + return nil, fmt.Errorf("unrecognized cleanup state file") + } + file, err := openPipelinePrivateFile(filepath.Join(s.root, name.Name())) + if err != nil { + return nil, err + } + data, err := io.ReadAll(io.LimitReader(file, maxPipelineRecordBytes+1)) + closeErr := file.Close() + if err != nil || closeErr != nil || len(data) > maxPipelineRecordBytes { + return nil, fmt.Errorf("unreadable cleanup record") + } + if err := validatePipelinePrivateJSON(data); err != nil { + return nil, fmt.Errorf("ambiguous cleanup record") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var entry pipelineCleanupEntry + if err := decoder.Decode(&entry); err != nil { + return nil, fmt.Errorf("malformed cleanup record") + } + if decoder.Decode(new(any)) != io.EOF { + return nil, fmt.Errorf("trailing cleanup record data") + } + if err := validatePipelineCleanupEntry(entry); err != nil { + return nil, err + } + if name.Name() != entry.Label+".json" { + return nil, fmt.Errorf("cleanup label/filename mismatch") + } + for _, cid := range entry.CIDFiles { + allowedCID[cid] = true + } + records = append(records, entry) + } + for _, name := range cidFiles { + if !allowedCID[name] { + return nil, fmt.Errorf("unowned cleanup cidfile") + } + file, err := openPipelinePrivateFile(filepath.Join(s.root, name)) + if err != nil { + return nil, err + } + if err := file.Close(); err != nil { + return nil, err + } + } + return records, nil +} + +func openPipelinePrivateFile(path string) (*os.File, error) { + fd, err := unix.Open(path, unix.O_RDONLY|unix.O_NONBLOCK|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, fmt.Errorf("unsafe cleanup record file: %w", err) + } + file := os.NewFile(uintptr(fd), "pipeline cleanup record") // #nosec G115 -- successful unix.Open returns a nonnegative descriptor. + info, err := file.Stat() + if err == nil { + err = validatePipelinePrivateFile(info, 0600, false) + } + if err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +func (s *pipelineCleanupState) Delete(label string) error { + if !validPipelineCleanupLabel(label) { + return fmt.Errorf("invalid cleanup label") + } + if err := os.Remove(filepath.Join(s.root, label+".json")); err != nil { + return err + } + return s.sync() +} + +func (s *pipelineCleanupState) sync() error { + directory, err := os.Open(s.root) + if err != nil { + return err + } + defer directory.Close() + return directory.Sync() +} diff --git a/cmd/wfctl/pipeline_cleanup_unix_test.go b/cmd/wfctl/pipeline_cleanup_unix_test.go new file mode 100644 index 00000000..0a7e2341 --- /dev/null +++ b/cmd/wfctl/pipeline_cleanup_unix_test.go @@ -0,0 +1,223 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" + + "golang.org/x/sys/unix" +) + +func TestPipelineCleanupStateLockAndPrivateFiles(t *testing.T) { + root := filepath.Join(cleanupTestDirectory(t), "cleanup") + state, err := openPipelineCleanupState(root) + if err != nil { + t.Fatal(err) + } + defer state.Close() + if other, err := openPipelineCleanupState(root); err == nil { + other.Close() + t.Fatal("a second parent acquired the execution cleanup lock") + } + entry := cleanupTestEntry(t) + if err := state.Save(entry); err != nil { + t.Fatal(err) + } + for name, mode := range map[string]os.FileMode{root: 0700, filepath.Join(root, "lock"): 0600, filepath.Join(root, entry.Label+".json"): 0600} { + info, err := os.Lstat(name) + if err != nil || info.Mode().Perm() != mode { + t.Fatalf("cleanup custody permissions for %s: %v, %v", name, info, err) + } + } + entries, err := state.Entries() + if err != nil || len(entries) != 1 || entries[0].Label != entry.Label { + t.Fatalf("retained journal was not read back: %#v, %v", entries, err) + } + if err := state.Delete(entry.Label); err != nil { + t.Fatal(err) + } + entries, err = state.Entries() + if err != nil || len(entries) != 0 { + t.Fatalf("journal deletion was not durable: %#v, %v", entries, err) + } +} + +func TestPipelineCleanupStateRejectsUnsafeCustody(t *testing.T) { + for _, kind := range []string{"root mode", "root symlink", "lock symlink", "record mode", "record symlink", "unknown field", "duplicate field", "truncated", "unexpected file"} { + t.Run(kind, func(t *testing.T) { + base := cleanupTestDirectory(t) + root := filepath.Join(base, "cleanup") + if err := os.Mkdir(root, 0700); err != nil { + t.Fatal(err) + } + switch kind { + case "root mode": + if err := os.Chmod(root, 0755); err != nil { + t.Fatal(err) + } + case "root symlink": + root = filepath.Join(base, "linked") + if err := os.Symlink(filepath.Join(base, "cleanup"), root); err != nil { + t.Fatal(err) + } + case "lock symlink": + if err := os.Symlink(filepath.Join(base, "foreign"), filepath.Join(root, "lock")); err != nil { + t.Fatal(err) + } + } + state, err := openPipelineCleanupState(root) + if kind == "root mode" || kind == "root symlink" || kind == "lock symlink" { + if err == nil { + state.Close() + t.Fatal("accepted unsafe cleanup custody") + } + return + } + if err != nil { + t.Fatal(err) + } + defer state.Close() + entry := cleanupTestEntry(t) + data, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, entry.Label+".json") + switch kind { + case "unknown field": + data = append(data[:len(data)-1], []byte(`,"foreign":true}`)...) + case "duplicate field": + data = append(data[:len(data)-1], []byte(`,"version":1}`)...) + case "truncated": + data = data[:len(data)/2] + case "unexpected file": + path = filepath.Join(root, "unowned") + case "record symlink": + foreign := filepath.Join(base, "foreign") + if err := os.WriteFile(foreign, data, 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(foreign, path); err != nil { + t.Fatal(err) + } + } + if kind != "record symlink" { + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + } + if kind == "record mode" { + if err := os.Chmod(path, 0644); err != nil { + t.Fatal(err) + } + } + if _, err := state.Entries(); err == nil { + t.Fatal("accepted an unsafe retained cleanup record") + } + }) + } +} + +func TestPipelineCleanupStateRejectsFIFO(t *testing.T) { + for _, kind := range []string{"journal", "cidfile"} { + t.Run(kind, func(t *testing.T) { + root := filepath.Join(cleanupTestDirectory(t), "cleanup") + state, err := openPipelineCleanupState(root) + if err != nil { + t.Fatal(err) + } + defer state.Close() + entry := cleanupTestEntry(t) + name := entry.Label + ".json" + if kind == "cidfile" { + name = entry.Label + "-0.cid" + entry.CIDFiles = []string{name} + if err := state.Save(entry); err != nil { + t.Fatal(err) + } + } + if err := unix.Mkfifo(filepath.Join(root, name), 0600); err != nil { + t.Fatal(err) + } + if err := state.Close(); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPipelineCleanupFIFOHelper$") + cmd.Env = append(os.Environ(), "WFCTL_PIPELINE_FIFO_HELPER=1", "WFCTL_PIPELINE_FIFO_ROOT="+root) + cmd.WaitDelay = 250 * time.Millisecond + output, err := cmd.CombinedOutput() + if !bytes.Contains(output, []byte("fifo-ready\n")) { + t.Fatalf("FIFO helper did not reach the actual journal read: %v, %s", err, output) + } + if ctx.Err() != nil { + t.Fatalf("%s FIFO open blocked instead of failing closed; helper killed and reaped: %v", kind, err) + } + if err != nil || !bytes.Contains(output, []byte("fifo-rejected\n")) { + t.Fatalf("FIFO custody was not rejected: %v, %s", err, output) + } + }) + } +} + +func TestPipelineCleanupFIFOHelper(t *testing.T) { + if os.Getenv("WFCTL_PIPELINE_FIFO_HELPER") != "1" { + return + } + state, err := openPipelineCleanupState(os.Getenv("WFCTL_PIPELINE_FIFO_ROOT")) + if err != nil { + t.Fatal(err) + } + defer state.Close() + fmt.Fprintln(os.Stdout, "fifo-ready") + if _, err := state.Entries(); err == nil { + t.Fatal("accepted FIFO custody") + } + fmt.Fprintln(os.Stdout, "fifo-rejected") +} + +func TestPipelineProcessStartToken(t *testing.T) { + first, err := pipelineProcessStartToken(os.Getpid()) + if err != nil || first == "" { + t.Fatalf("missing process identity: %q, %v", first, err) + } + second, err := pipelineProcessStartToken(os.Getpid()) + if err != nil || second != first { + t.Fatalf("unstable process identity: %q, %v", second, err) + } + active, err := pipelineProcessGroupActive(syscall.Getpgrp()) + if err != nil || !active { + t.Fatalf("current group missing from process census: %v, %v", active, err) + } +} + +func cleanupTestEntry(t *testing.T) pipelineCleanupEntry { + t.Helper() + token, err := pipelineProcessStartToken(os.Getpid()) + if err != nil { + t.Fatal(err) + } + return pipelineCleanupEntry{Version: 1, Label: "wfctl-0123456789abcdef0123456789abcdef", + Parent: pipelineProcessIdentity{PID: os.Getpid(), Start: token}, + Docker: pipelineDockerIdentity{Endpoint: "unix:///fixture/docker.sock", TLSHash: "none", ServerID: "fixture-A"}, + Deadline: time.Now().Add(time.Minute).UTC().Format(time.RFC3339Nano)} +} + +func cleanupTestDirectory(t *testing.T) string { + t.Helper() + path, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return path +} diff --git a/cmd/wfctl/pipeline_closure.go b/cmd/wfctl/pipeline_closure.go new file mode 100644 index 00000000..d739b8a8 --- /dev/null +++ b/cmd/wfctl/pipeline_closure.go @@ -0,0 +1,317 @@ +package main + +import ( + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/plugin" + pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" +) + +type pipelineClosure struct { + config *config.WorkflowConfig + types map[string]bool + names map[string]int +} + +// Walk config, not factories: even a rejected closure must execute no plugin code. +func selectPipelineClosure(cfg *config.WorkflowConfig, name string, record bool) (*pipelineClosure, error) { + if record && (len(cfg.Modules) != 0 || len(cfg.Workflows) != 0 || len(cfg.Triggers) != 0 || len(cfg.Sidecars) != 0) { + return nil, fmt.Errorf("record mode rejects modules, routes, triggers, and sidecars") + } + selected := *cfg + selected.Pipelines = make(map[string]any) + if record { + selected = config.WorkflowConfig{ConfigDir: cfg.ConfigDir, Pipelines: selected.Pipelines} + } + closure := &pipelineClosure{config: &selected, types: make(map[string]bool), names: make(map[string]int)} + visiting := make(map[string]bool) + var pipeline func(string) error + var steps func(any, string, string, int) error + var step func(map[string]any, string, int) error + + steps = func(raw any, parent, naming string, depth int) error { + list, ok := raw.([]any) + if !ok { + return fmt.Errorf("%s: steps must be a list", parent) + } + for i, rawStep := range list { + cfgStep, ok := rawStep.(map[string]any) + if !ok { + return fmt.Errorf("%s: step %d must be an object", parent, i) + } + fallback := "" + switch naming { + case "indexed": + fallback = fmt.Sprintf("%s[%d]", parent, i) + case "sub": + fallback = fmt.Sprintf("%s-sub-%d", parent, i) + } + if err := step(cfgStep, fallback, depth+1); err != nil { + return err + } + } + return nil + } + step = func(raw map[string]any, fallbackName string, depth int) error { + if depth > 64 { + return fmt.Errorf("pipeline closure nesting exceeds 64") + } + stepType, _ := raw["type"].(string) + if stepType == "" || strings.ContainsAny(stepType, "{}") { + return fmt.Errorf("%s: step type must be literal", fallbackName) + } + stepName, _ := raw["name"].(string) + if stepName == "" { + stepName = fallbackName + } + if stepName != "" { + closure.names[stepName]++ + } + closure.types[stepType] = true + stepCfg := raw + if inner, exists := raw["config"]; exists { + var ok bool + stepCfg, ok = inner.(map[string]any) + if !ok && inner != nil { + return fmt.Errorf("%s: config must be an object", stepName) + } + } + single := func(field string, required bool) error { + value, exists := stepCfg[field] + if !exists && !required { + return nil + } + child, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("%s: %s must be a step object", stepName, field) + } + return step(child, stepName+"-"+field, depth+1) + } + list := func(field string) error { + if (stepType == "step.foreach" || stepType == "step.while") && field == "steps" { + return steps(stepCfg[field], stepName, "sub", depth+1) + } + naming := "indexed" + if stepType == "step.parallel" { + naming = "" + } + return steps(stepCfg[field], stepName+"-"+field, naming, depth+1) + } + switch stepType { + case "step.sub_workflow": + return fmt.Errorf("%s: runtime-owned sub_workflow is not a static closure", stepName) + case "step.workflow_call": + target, _ := stepCfg["workflow"].(string) + if target == "" || strings.ContainsAny(target, "{}") { + return fmt.Errorf("%s: dynamic workflow target is not permitted", stepName) + } + return pipeline(target) + case "step.branch": + branches, ok := stepCfg["branches"].(map[string]any) + if !ok || len(branches) == 0 { + return fmt.Errorf("%s: branches must be a nonempty object", stepName) + } + for _, key := range slices.Sorted(maps.Keys(branches)) { + if err := steps(branches[key], fmt.Sprintf("%s-%s", stepName, key), "indexed", depth+1); err != nil { + return err + } + } + if _, exists := stepCfg["default"]; exists { + return list("default") + } + case "step.foreach", "step.while": + _, hasStep := stepCfg["step"] + _, hasSteps := stepCfg["steps"] + if hasStep && hasSteps { + return fmt.Errorf("%s: step and steps are mutually exclusive", stepName) + } + if hasStep { + return single("step", true) + } + if hasSteps { + return list("steps") + } + case "step.parallel": + return list("steps") + case "step.retry_with_backoff": + return single("step", true) + case "step.resilient_circuit_breaker": + if err := single("step", true); err != nil { + return err + } + return single("fallback", false) + default: + if record { + for _, field := range []string{"step", "steps", "branches", "fallback"} { + if _, exists := stepCfg[field]; exists { + return fmt.Errorf("%s: unsupported composite/control-flow form", stepName) + } + } + } + } + return nil + } + pipeline = func(name string) error { + if len(visiting) >= 64 { + return fmt.Errorf("pipeline call closure exceeds 64") + } + if visiting[name] { + return fmt.Errorf("cyclic workflow target %q", name) + } + if _, ok := selected.Pipelines[name]; ok { + return nil + } + raw, ok := cfg.Pipelines[name].(map[string]any) + if !ok { + return fmt.Errorf("pipeline %q not found or not an object", name) + } + if record { + if raw["on_error"] == "skip" { + return fmt.Errorf("record mode rejects skipped pipeline errors") + } + if trigger, exists := raw["trigger"]; exists && trigger != nil { + return fmt.Errorf("record mode rejects pipeline triggers") + } + } + visiting[name] = true + defer delete(visiting, name) + if err := steps(raw["steps"], name, "", 0); err != nil { + return err + } + if compensation, exists := raw["compensation"]; exists { + if err := steps(compensation, name+"-compensation", "", 0); err != nil { + return err + } + } + selected.Pipelines[name] = maps.Clone(raw) + return nil + } + if err := pipeline(name); err != nil { + return nil, err + } + return closure, nil +} + +type pipelineInstallations struct { + manifests map[string]*plugin.PluginManifest + owners map[string]string +} + +func inspectPipelineInstallations(dir string) (*pipelineInstallations, error) { + installed := &pipelineInstallations{ + manifests: make(map[string]*plugin.PluginManifest), + owners: make(map[string]string), + } + for _, stepType := range pluginpipeline.New().EngineManifest().StepTypes { + installed.owners[stepType] = "pipeline-steps" + } + if dir == "" { + return installed, nil + } + entries, err := os.ReadDir(dir) + if os.IsNotExist(err) { + return installed, nil + } + if err != nil { + return nil, fmt.Errorf("inspect plugin installations: %w", err) + } + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + manifestPath := filepath.Join(path, "plugin.json") + if _, err := os.Lstat(manifestPath); os.IsNotExist(err) { + continue + } + if !entry.IsDir() || entry.Type()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("plugin installation identity: directory must not be a symlink") + } + manifest, err := plugin.LoadManifest(manifestPath) + if err != nil { + return nil, fmt.Errorf("inspect plugin manifest: %w", err) + } + if err := manifest.Validate(); err != nil { + return nil, err + } + if manifest.Name != entry.Name() { + return nil, fmt.Errorf("plugin installation identity: directory %q does not match manifest %q", entry.Name(), manifest.Name) + } + for _, filename := range []string{"plugin.json", manifest.Name} { + info, err := os.Lstat(filepath.Join(path, filename)) + if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("plugin installation identity: %q must be a regular file", filename) + } + } + installed.manifests[manifest.Name] = manifest + for _, stepType := range manifest.StepTypes { + if owner, exists := installed.owners[stepType]; exists { + return nil, fmt.Errorf("step ownership collision: %q belongs to %q and %q", stepType, owner, manifest.Name) + } + installed.owners[stepType] = manifest.Name + } + } + return installed, nil +} + +func (installed *pipelineInstallations) resolve(types map[string]bool) ([]string, error) { + required := make(map[string]bool) + for _, stepType := range slices.Sorted(maps.Keys(types)) { + owner, ok := installed.owners[stepType] + if !ok { + return nil, fmt.Errorf("unknown step ownership: %q", stepType) + } + if owner != "pipeline-steps" { + required[owner] = true + } + } + var order []string + visited := make(map[string]bool) + active := make(map[string]bool) + var visit func(string) error + visit = func(name string) error { + if active[name] || len(active) >= 64 { + return fmt.Errorf("cyclic or overdeep plugin dependency") + } + if visited[name] { + return nil + } + manifest, ok := installed.manifests[name] + if !ok { + return fmt.Errorf("missing plugin dependency %q", name) + } + active[name] = true + defer delete(active, name) + dependencies := slices.Clone(manifest.Dependencies) + slices.SortFunc(dependencies, func(a, b plugin.Dependency) int { return strings.Compare(a.Name, b.Name) }) + for _, dependency := range dependencies { + version := "" + if dependency.Name == "pipeline-steps" { + version = pluginpipeline.New().Version() + } else if found := installed.manifests[dependency.Name]; found != nil { + version = found.Version + } + matches, err := plugin.CheckVersion(version, dependency.Constraint) + if err != nil || !matches { + return fmt.Errorf("missing or incompatible plugin dependency %q", dependency.Name) + } + if dependency.Name != "pipeline-steps" { + if err := visit(dependency.Name); err != nil { + return err + } + } + } + visited[name] = true + order = append(order, name) + return nil + } + for _, name := range slices.Sorted(maps.Keys(required)) { + if err := visit(name); err != nil { + return nil, err + } + } + return order, nil +} diff --git a/cmd/wfctl/pipeline_closure_test.go b/cmd/wfctl/pipeline_closure_test.go new file mode 100644 index 00000000..6813924a --- /dev/null +++ b/cmd/wfctl/pipeline_closure_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/plugin" +) + +func TestPipelineClosureWalksEveryStaticComposite(t *testing.T) { + leaf := map[string]any{"name": "called", "type": "step.workflow_call", "config": map[string]any{"workflow": "child"}} + for kind, childConfig := range map[string]map[string]any{ + "step.branch": {"branches": map[string]any{"case": []any{leaf}}, "default": []any{leaf}}, + "step.foreach": {"step": leaf}, + "step.while": {"steps": []any{leaf}}, + "step.parallel": {"steps": []any{leaf}}, + "step.retry_with_backoff": {"step": leaf}, + "step.resilient_circuit_breaker": {"step": leaf, "fallback": leaf}, + } { + t.Run(kind, func(t *testing.T) { + cfg := &config.WorkflowConfig{Pipelines: map[string]any{ + "selected": map[string]any{ + "steps": []any{map[string]any{"name": "composite", "type": kind, "config": childConfig}}, + "compensation": []any{map[string]any{"name": "undo", "type": "step.fixture"}}, + }, + "child": map[string]any{"steps": []any{map[string]any{"name": "result", "type": "step.set"}}}, + "unrelated": map[string]any{"steps": []any{map[string]any{"type": "step.not_installed"}}}, + }} + before, _ := json.Marshal(cfg) + closure, err := selectPipelineClosure(cfg, "selected", true) + if err != nil { + t.Fatal(err) + } + if len(closure.config.Pipelines) != 2 || !closure.types["step.fixture"] || !closure.types["step.set"] { + t.Fatalf("incomplete static closure: %#v, %#v", closure.config.Pipelines, closure.types) + } + after, _ := json.Marshal(cfg) + if string(before) != string(after) { + t.Fatal("preflight mutated application config") + } + }) + } +} + +func TestPipelineClosureNamesMatchNativeFactories(t *testing.T) { + for _, kind := range []string{"step.foreach", "step.while"} { + cfg := &config.WorkflowConfig{Pipelines: map[string]any{"selected": map[string]any{ + "steps": []any{ + map[string]any{"type": "step.set"}, + map[string]any{"name": "loop", "type": kind, "config": map[string]any{"steps": []any{ + map[string]any{"type": "step.set"}, map[string]any{"type": "step.set"}, + }}}, + }, + }}} + closure, err := selectPipelineClosure(cfg, "selected", true) + if err != nil { + t.Fatal(err) + } + if closure.names["loop-sub-0"] != 1 || closure.names["loop-sub-1"] != 1 || closure.names["selected[0]"] != 0 { + t.Fatalf("closure invented names unavailable from native %s factories: %#v", kind, closure.names) + } + } +} + +func TestPipelineClosureDeniesRuntimeAndUnknownForms(t *testing.T) { + for name, step := range map[string]map[string]any{ + "dynamic": {"type": "step.workflow_call", "config": map[string]any{"workflow": "{{ .target }}"}}, + "plugin child": {"type": "step.sub_workflow"}, + "unknown composite": {"type": "step.fixture", "config": map[string]any{"steps": []any{}}}, + "cycle": {"type": "step.workflow_call", "config": map[string]any{"workflow": "selected"}}, + } { + t.Run(name, func(t *testing.T) { + cfg := &config.WorkflowConfig{Pipelines: map[string]any{ + "selected": map[string]any{"steps": []any{step}}, + }} + if _, err := selectPipelineClosure(cfg, "selected", true); err == nil { + t.Fatal("accepted runtime/unknown executable closure") + } + }) + } +} + +func TestPipelineClosureRejectsNestedSkippedErrors(t *testing.T) { + cfg := &config.WorkflowConfig{Pipelines: map[string]any{ + "selected": map[string]any{"steps": []any{map[string]any{ + "name": "call", "type": "step.workflow_call", "config": map[string]any{"workflow": "child"}, + }}}, + "child": map[string]any{"on_error": "skip", "steps": []any{map[string]any{ + "name": "result", "type": "step.set", "config": map[string]any{"values": map[string]any{"ready": true}}, + }}}, + }} + if _, err := selectPipelineClosure(cfg, "selected", true); err == nil || !strings.Contains(err.Error(), "skipped pipeline errors") { + t.Fatalf("literal child bypassed the record error contract: %v", err) + } + if _, err := selectPipelineClosure(cfg, "selected", false); err != nil { + t.Fatalf("record-only restriction changed human closure: %v", err) + } +} + +func TestPipelineInstallationDependencyOrderAndDenial(t *testing.T) { + installed := &pipelineInstallations{ + owners: map[string]string{"step.fixture": "fixture"}, + manifests: map[string]*plugin.PluginManifest{ + "fixture": {Name: "fixture", Version: "1.0.0", Dependencies: []plugin.Dependency{{Name: "dependency", Constraint: ">=1.0.0"}}}, + "dependency": {Name: "dependency", Version: "1.0.0"}, + "unused": {Name: "unused", Version: "1.0.0"}, + }, + } + types := map[string]bool{"step.fixture": true} + got, err := installed.resolve(types) + if err != nil || strings.Join(got, ",") != "dependency,fixture" { + t.Fatalf("dependency start order = %v, %v", got, err) + } + delete(installed.manifests, "dependency") + if _, err := installed.resolve(types); err == nil { + t.Fatal("missing dependency was accepted") + } + if _, err := installed.resolve(map[string]bool{"step.unknown": true}); err == nil { + t.Fatal("unknown step ownership was accepted") + } +} + +func TestPipelineRunPrunesUnselectedPipeline(t *testing.T) { + path := writePipelineConfig(t, t.TempDir(), "application.yaml", ` +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.set + config: + values: {ready: "true"} + unrelated: + steps: + - name: unavailable + type: step.not_installed +`) + if err := runPipelineRun([]string{"-c", path, "-p", "selected"}); err != nil { + t.Fatalf("unselected pipeline must not be compiled: %v", err) + } +} + +func TestPipelineRunKeepsLiteralCallClosure(t *testing.T) { + path := writePipelineConfig(t, t.TempDir(), "application.yaml", ` +modules: [] +pipelines: + selected: + steps: + - name: call + type: step.workflow_call + config: {workflow: child} + child: + steps: + - name: result + type: step.set + config: + values: {ready: "true"} + unrelated: + steps: + - name: unavailable + type: step.not_installed +`) + if err := runPipelineRun([]string{"-c", path, "-p", "selected"}); err != nil { + t.Fatalf("literal child closure must be compiled without unrelated pipeline: %v", err) + } +} + +func TestPipelineRunRejectsDynamicCallBeforeStartup(t *testing.T) { + path := writePipelineConfig(t, t.TempDir(), "application.yaml", ` +modules: [] +pipelines: + selected: + steps: + - name: call + type: step.workflow_call + config: {workflow: "{{ .target }}"} +`) + err := runPipelineRun([]string{"-c", path, "-p", "selected", "--input", `{"target":"absent"}`}) + if err == nil || !strings.Contains(err.Error(), "dynamic workflow target") { + t.Fatalf("dynamic closure must fail during preflight: %v", err) + } +} + +func TestPipelineRunRejectsOwnershipCollisionBeforeStartup(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "plugin-started") + for _, name := range []string{"first", "second"} { + installed := filepath.Join(dir, name) + if err := os.Mkdir(installed, 0700); err != nil { + t.Fatal(err) + } + manifest := `{"name":"` + name + `","version":"1.0.0","author":"fixture","description":"startup canary","stepTypes":["step.fixture"]}` + if err := os.WriteFile(filepath.Join(installed, "plugin.json"), []byte(manifest), 0600); err != nil { + t.Fatal(err) + } + // This is a startup canary, not a simulated external-plugin implementation. + script := "#!/bin/sh\nprintf started > '" + marker + "'\nexit 1\n" + if err := os.WriteFile(filepath.Join(installed, name), []byte(script), 0700); err != nil { + t.Fatal(err) + } + } + path := writePipelineConfig(t, t.TempDir(), "application.yaml", ` +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.fixture +`) + err := runPipelineRun([]string{"-c", path, "-p", "selected", "--plugin-dir", dir}) + if err == nil || !strings.Contains(err.Error(), "ownership collision") { + t.Errorf("want manifest ownership collision before startup, got %v", err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("plugin code ran before manifest validation: %v", err) + } +} + +func TestPipelineRunRejectsUnreferencedMovedIdentityBeforeStartup(t *testing.T) { + dir := t.TempDir() + installed := filepath.Join(dir, "moved") + if err := os.Mkdir(installed, 0700); err != nil { + t.Fatal(err) + } + manifest := `{"name":"original","version":"1.0.0","author":"fixture","description":"moved identity","stepTypes":["step.fixture"]}` + if err := os.WriteFile(filepath.Join(installed, "plugin.json"), []byte(manifest), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "moved"), []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + path := writePipelineConfig(t, t.TempDir(), "application.yaml", ` +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.set + config: + values: {ready: "true"} +`) + err := runPipelineRun([]string{"-c", path, "-p", "selected", "--plugin-dir", dir}) + if err == nil || !strings.Contains(err.Error(), "installation identity") { + t.Fatalf("want canonical installation identity denial, got %v", err) + } +} diff --git a/cmd/wfctl/pipeline_docker_unix.go b/cmd/wfctl/pipeline_docker_unix.go new file mode 100644 index 00000000..59b74294 --- /dev/null +++ b/cmd/wfctl/pipeline_docker_unix.go @@ -0,0 +1,615 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/GoCodeAlone/workflow/sandbox" + "golang.org/x/sys/unix" +) + +const ( + pipelineDockerCallTimeout = 5 * time.Second + pipelineDockerCleanupTimeout = 30 * time.Second + pipelineDockerCleanupLabelKey = "wfctl.pipeline.cleanup" +) + +type pipelineDockerClient struct { + Identity pipelineDockerIdentity + + mu sync.RWMutex + identity pipelineDockerIdentity + binary string + env []string + args []string + configDir string + tls bool + verifyTLS bool + tlsFiles [3]bool + closed bool +} + +type pipelineDockerContext struct { + Name string + Endpoints map[string]struct { + Host string + SkipTLSVerify bool + } + TLSMaterial map[string][]string + Storage struct{ TLSPath string } +} + +type pipelineDockerProcessGroupKey struct{} + +var pipelineDockerTLSNames = [3]string{"ca.pem", "cert.pem", "key.pem"} + +func resolvePipelineDockerClient(ctx context.Context) (*pipelineDockerClient, error) { + // Only discovery receives a snapshot of Docker's transport-selection env. + // All daemon calls use a private empty config, explicit host/TLS flags, and + // a minimal env, avoiding auth helpers, config proxies, and context changes. + snapshot := make(map[string]string) + for _, item := range os.Environ() { + key, value, _ := strings.Cut(item, "=") + snapshot[key] = value + } + binary, err := exec.LookPath("docker") + if err != nil { + return nil, fmt.Errorf("pipeline Docker CLI unavailable") + } + binary, err = filepath.Abs(binary) + if err != nil { + return nil, fmt.Errorf("pipeline Docker CLI unavailable") + } + home := snapshot["HOME"] + if home == "" { + return nil, fmt.Errorf("pipeline Docker home unavailable") + } + config := snapshot["DOCKER_CONFIG"] + if config == "" { + config = filepath.Join(home, ".docker") + } + config, err = filepath.Abs(config) + if err != nil { + return nil, fmt.Errorf("pipeline Docker config unavailable") + } + env := []string{"PATH=" + snapshot["PATH"], "HOME=" + home, "LANG=C", "LC_ALL=C"} + discoveryEnv := slices.Clone(env) + for _, key := range []string{"DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH"} { + if value := snapshot[key]; value != "" { + discoveryEnv = append(discoveryEnv, key+"="+value) + } + } + out, err := runPipelineDockerCLI(ctx, binary, discoveryEnv, pipelineDockerCallTimeout, "--config", config, "context", "inspect") + if err != nil { + return nil, fmt.Errorf("pipeline Docker context resolution failed") + } + var contexts []pipelineDockerContext + if json.Unmarshal([]byte(out.Stdout), &contexts) != nil || len(contexts) != 1 { + return nil, fmt.Errorf("pipeline Docker context metadata invalid") + } + metadata := contexts[0] + endpoint, ok := metadata.Endpoints["docker"] + if !ok || metadata.Name == "" { + return nil, fmt.Errorf("pipeline Docker context endpoint missing") + } + if err := validatePipelineDockerEndpoint(endpoint.Host); err != nil { + return nil, err + } + var material [3][]byte + tlsEnabled := false + verifyTLS := false + if strings.HasPrefix(endpoint.Host, "tcp://") { + if metadata.Name == "default" { + tlsEnabled = snapshot["DOCKER_TLS"] != "" || snapshot["DOCKER_TLS_VERIFY"] != "" + verifyTLS = snapshot["DOCKER_TLS_VERIFY"] != "" + if tlsEnabled { + certPath := snapshot["DOCKER_CERT_PATH"] + if certPath == "" { + certPath = config + } + for i, name := range pipelineDockerTLSNames { + material[i], err = readPipelineDockerFile(filepath.Join(certPath, name), false, false) + if err != nil { + return nil, err + } + } + } + } else { + names := metadata.TLSMaterial["docker"] + tlsEnabled = len(names) != 0 || endpoint.SkipTLSVerify + verifyTLS = tlsEnabled && !endpoint.SkipTLSVerify + seen := make(map[string]bool) + for _, name := range names { + i := slices.Index(pipelineDockerTLSNames[:], name) + if i < 0 || seen[name] || !filepath.IsAbs(metadata.Storage.TLSPath) { + return nil, fmt.Errorf("pipeline Docker TLS metadata invalid") + } + seen[name] = true + material[i], err = readPipelineDockerFile(filepath.Join(metadata.Storage.TLSPath, "docker", name), true, false) + if err != nil { + return nil, err + } + } + } + } + if (len(material[1]) == 0) != (len(material[2]) == 0) { + return nil, fmt.Errorf("pipeline Docker TLS client material incomplete") + } + dir, err := os.MkdirTemp("", "wfctl-pipeline-docker-") + if err != nil { + return nil, fmt.Errorf("pipeline Docker private config unavailable") + } + c := &pipelineDockerClient{binary: binary, env: env, configDir: dir, tls: tlsEnabled, verifyTLS: verifyTLS} + success := false + defer func() { + if !success { + _ = c.Close() + } + }() + if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte("{}\n"), 0600); err != nil { + return nil, fmt.Errorf("pipeline Docker private config unavailable") + } + c.args = []string{"--config", dir, "--host", endpoint.Host, "--tls=" + strconv.FormatBool(tlsEnabled)} + if tlsEnabled { + c.args = append(c.args, "--tlsverify="+strconv.FormatBool(verifyTLS)) + for i, flag := range []string{"--tlscacert", "--tlscert", "--tlskey"} { + path := "" + if len(material[i]) > 0 { + path = filepath.Join(dir, pipelineDockerTLSNames[i]) + if err := os.WriteFile(path, material[i], 0600); err != nil { + return nil, fmt.Errorf("pipeline Docker private TLS material unavailable") + } + c.tlsFiles[i] = true + } + c.args = append(c.args, flag+"="+path) + } + } + serverID, err := c.serverID(ctx) + if err != nil { + return nil, err + } + c.identity = pipelineDockerIdentity{Endpoint: endpoint.Host, TLSHash: hashPipelineDockerTLS(tlsEnabled, verifyTLS, material), ServerID: serverID} + c.Identity = c.identity + success = true + return c, nil +} + +func validatePipelineDockerEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err == nil && u.Scheme == "ssh" { + return fmt.Errorf("pipeline Docker SSH endpoints are unsupported in record mode") + } + if err != nil || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(endpoint, "\x00\r\n\t ") { + return fmt.Errorf("pipeline Docker endpoint invalid") + } + switch u.Scheme { + case "unix": + if u.Host == "" && filepath.IsAbs(u.Path) && filepath.Clean(u.Path) == u.Path && u.Path != "/" { + return nil + } + case "tcp": + port, err := strconv.ParseUint(u.Port(), 10, 16) + if u.Hostname() != "" && err == nil && port > 0 && u.Path == "" { + return nil + } + } + return fmt.Errorf("pipeline Docker endpoint unsupported or invalid") +} + +func readPipelineDockerFile(path string, required, private bool) ([]byte, error) { + flags := unix.O_RDONLY | unix.O_CLOEXEC | unix.O_NONBLOCK + if private { + flags |= unix.O_NOFOLLOW + } + fd, err := unix.Open(path, flags, 0) + if err != nil { + if !required && errors.Is(err, unix.ENOENT) { + return nil, nil + } + return nil, fmt.Errorf("pipeline Docker material unreadable") + } + file := os.NewFile(uintptr(fd), "pipeline Docker material") // #nosec G115 -- successful unix.Open returns a nonnegative file descriptor. + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || (private && validatePipelinePrivateFile(info, 0600, false) != nil) { + return nil, fmt.Errorf("pipeline Docker material unsafe") + } + data, err := io.ReadAll(io.LimitReader(file, sandbox.MaxOutputBytes+1)) + if err != nil || len(data) > sandbox.MaxOutputBytes || len(data) == 0 { + return nil, fmt.Errorf("pipeline Docker material invalid") + } + return data, nil +} + +func hashPipelineDockerTLS(enabled, verify bool, material [3][]byte) string { + data, _ := json.Marshal(struct { + TLS bool + Verify bool + Material [3][]byte + }{enabled, verify, material}) + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func (c *pipelineDockerClient) validateSnapshot() error { + if c.closed || c.Identity != c.identity || c.identity.ServerID == "" { + return fmt.Errorf("pipeline Docker client identity unavailable or changed") + } + info, err := os.Lstat(c.configDir) + if err != nil || validatePipelinePrivateFile(info, 0700, true) != nil || info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("pipeline Docker private config unsafe") + } + config, err := readPipelineDockerFile(filepath.Join(c.configDir, "config.json"), true, true) + if err != nil || string(config) != "{}\n" { + return fmt.Errorf("pipeline Docker private config changed") + } + var material [3][]byte + for i, exists := range c.tlsFiles { + if exists { + material[i], err = readPipelineDockerFile(filepath.Join(c.configDir, pipelineDockerTLSNames[i]), true, true) + if err != nil { + return err + } + } + } + if hashPipelineDockerTLS(c.tls, c.verifyTLS, material) != c.identity.TLSHash { + return fmt.Errorf("pipeline Docker TLS identity changed") + } + return nil +} + +func (c *pipelineDockerClient) serverID(ctx context.Context) (string, error) { + result, err := c.raw(ctx, pipelineDockerCallTimeout, "info", "--format", "{{json .ID}}") + if err != nil { + return "", fmt.Errorf("pipeline Docker daemon identity unavailable") + } + var id string + if json.Unmarshal([]byte(result.Stdout), &id) != nil || len(id) == 0 || len(id) > 256 { + return "", fmt.Errorf("pipeline Docker daemon server ID invalid") + } + for _, char := range id { + if (char < 'a' || char > 'z') && (char < 'A' || char > 'Z') && (char < '0' || char > '9') && !strings.ContainsRune("-_:.", char) { + return "", fmt.Errorf("pipeline Docker daemon server ID invalid") + } + } + return id, nil +} + +func (c *pipelineDockerClient) raw(ctx context.Context, timeout time.Duration, args ...string) (*sandbox.ExecResult, error) { + return runPipelineDockerCLI(ctx, c.binary, c.env, timeout, append(slices.Clone(c.args), args...)...) +} + +// RunInProcessGroup keeps broker CLI processes and their helpers in the child's +// already-journaled group. Restart reconciliation must kill that group before +// probing the daemon, including when the parent died during Docker create. +func (c *pipelineDockerClient) RunInProcessGroup(ctx context.Context, process pipelineProcessIdentity, args ...string) (*sandbox.ExecResult, error) { + if err := validatePipelineDockerProcessGroup(process); err != nil { + return nil, err + } + return c.Run(context.WithValue(ctx, pipelineDockerProcessGroupKey{}, process), args...) +} + +func validatePipelineDockerProcessGroup(process pipelineProcessIdentity) error { + if process.PID <= 0 || process.PGID != process.PID || process.Start == "" { + return fmt.Errorf("pipeline Docker recorded process identity invalid") + } + token, err := pipelineProcessStartToken(process.PID) + if err != nil || token != process.Start { + return fmt.Errorf("pipeline Docker recorded process identity unavailable or changed") + } + group, err := unix.Getpgid(process.PID) + if err != nil || group != process.PGID { + return fmt.Errorf("pipeline Docker recorded process group mismatch") + } + active, err := pipelineProcessGroupActive(process.PGID) + if err != nil || !active { + return fmt.Errorf("pipeline Docker recorded process group unavailable") + } + return nil +} + +// Run revalidates the snapshot and server ID before each operation. No caller +// may override global transport flags; '--' separates a create command payload. +func (c *pipelineDockerClient) Run(ctx context.Context, args ...string) (*sandbox.ExecResult, error) { + c.mu.RLock() + defer c.mu.RUnlock() + if err := c.validateSnapshot(); err != nil { + return nil, err + } + if len(args) == 0 || !slices.Contains([]string{"create", "start", "attach", "wait", "ps", "inspect", "rm"}, args[0]) { + return nil, fmt.Errorf("pipeline Docker operation unsupported") + } + if _, grouped := ctx.Value(pipelineDockerProcessGroupKey{}).(pipelineProcessIdentity); args[0] == "create" && !grouped { + return nil, fmt.Errorf("pipeline Docker create requires a recorded process group") + } + if args[0] != "create" && args[0] != "ps" && !validPipelineDockerID(args[len(args)-1]) { + return nil, fmt.Errorf("pipeline Docker operation requires a full container ID") + } + for _, arg := range args[1:] { + if arg == "--" { + break + } + flag, _, _ := strings.Cut(arg, "=") + if strings.HasPrefix(arg, "-H") || strings.HasPrefix(arg, "-c") || + slices.Contains([]string{"--host", "--context", "--config", "--tls", "--tlsverify", "--tlscacert", "--tlscert", "--tlskey"}, flag) { + return nil, fmt.Errorf("pipeline Docker transport override forbidden") + } + } + ctx, cancel := context.WithTimeout(ctx, sandbox.RecordMaxTimeout) + defer cancel() + id, err := c.serverID(ctx) + if err != nil { + return nil, err + } + if id != c.identity.ServerID { + return nil, fmt.Errorf("pipeline Docker daemon identity mismatch") + } + timeout := pipelineDockerCallTimeout + if slices.Contains([]string{"start", "attach", "wait"}, args[0]) { + timeout = sandbox.RecordMaxTimeout + } + return c.raw(ctx, timeout, args...) +} + +type pipelineDockerOutput struct { + buffer bytes.Buffer + overflow bool +} + +func (w *pipelineDockerOutput) Write(data []byte) (int, error) { + count := len(data) + remaining := sandbox.MaxOutputBytes - w.buffer.Len() + if len(data) > remaining { + w.overflow = true + data = data[:remaining] + } + _, _ = w.buffer.Write(data) + return count, nil +} + +func runPipelineDockerCLI(ctx context.Context, binary string, env []string, timeout time.Duration, args ...string) (*sandbox.ExecResult, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + process, grouped := ctx.Value(pipelineDockerProcessGroupKey{}).(pipelineProcessIdentity) + if grouped { + if err := validatePipelineDockerProcessGroup(process); err != nil { + return nil, err + } + } + // Docker CLI newCIDFile uses os.Create (cli/command/container/create.go). + // Set only the CLI umask; exec preserves PID/PGID and literal positional argv. + argv := append([]string{"-c", `umask 077; exec "$@"`, "wfctl-docker", binary}, args...) + cmd := exec.CommandContext(ctx, "/bin/sh", argv...) // #nosec G204,G702 -- fixed shell program; values remain positional argv. + cmd.Env = env + cmd.WaitDelay = 250 * time.Millisecond + // Broker helpers belong to recorded child custody; discovery/cleanup helpers + // get a temporary group. Never kill the recorded group from a CLI cancel. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if grouped { + cmd.SysProcAttr.Pgid = process.PGID + } + cmd.Cancel = func() error { + if grouped { + return cmd.Process.Kill() + } + err := unix.Kill(-cmd.Process.Pid, unix.SIGKILL) + if errors.Is(err, unix.ESRCH) { + return os.ErrProcessDone + } + return err + } + var stdout, stderr pipelineDockerOutput + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if cmd.Process != nil && !grouped { + _ = unix.Kill(-cmd.Process.Pid, unix.SIGKILL) + } + result := &sandbox.ExecResult{ExitCode: -1, Stdout: stdout.buffer.String(), Stderr: stderr.buffer.String()} + if cmd.ProcessState != nil { + result.ExitCode = cmd.ProcessState.ExitCode() + } + if ctx.Err() != nil { + return result, fmt.Errorf("pipeline Docker operation canceled or timed out") + } + if stdout.overflow || stderr.overflow { + return result, fmt.Errorf("pipeline Docker output exceeded capture limit") + } + if err != nil { + return result, fmt.Errorf("pipeline Docker operation failed") + } + return result, nil +} + +func validPipelineDockerID(id string) bool { + if len(id) != 64 || strings.ToLower(id) != id { + return false + } + _, err := hex.DecodeString(id) + return err == nil +} + +func (c *pipelineDockerClient) list(ctx context.Context, filter string) ([]string, error) { + result, err := c.Run(ctx, "ps", "--all", "--quiet", "--no-trunc", "--filter", filter) + if err != nil { + return nil, err + } + if result.Stdout == "" { + return nil, nil + } + ids := strings.Split(strings.TrimSuffix(result.Stdout, "\n"), "\n") + seen := make(map[string]bool) + for _, id := range ids { + if !validPipelineDockerID(id) || seen[id] { + return nil, fmt.Errorf("pipeline Docker container list invalid") + } + seen[id] = true + } + return ids, nil +} + +func (c *pipelineDockerClient) listID(ctx context.Context, id string) ([]string, error) { + ids, err := c.list(ctx, "id="+id) + if err != nil { + return nil, err + } + if len(ids) > 1 || len(ids) == 1 && ids[0] != id { + return nil, fmt.Errorf("pipeline Docker exact ID query mismatch") + } + return ids, nil +} + +func (c *pipelineDockerClient) verifyContainer(ctx context.Context, id, label string) error { + result, err := c.Run(ctx, "inspect", "--type", "container", "--format", `{"id":{{json .Id}},"labels":{{json .Config.Labels}}}`, id) + if err != nil { + return err + } + var inspected struct { + ID string `json:"id"` + Labels map[string]string `json:"labels"` + } + if json.Unmarshal([]byte(result.Stdout), &inspected) != nil || inspected.ID != id || inspected.Labels[pipelineDockerCleanupLabelKey] != label { + return fmt.Errorf("pipeline Docker container identity or cleanup label mismatch") + } + return nil +} + +// Cleanup never mutates journal/CID files. Only the parent can retire them after +// this exact-ID and exact-label reconciliation has successfully read back empty. +func (c *pipelineDockerClient) Cleanup(ctx context.Context, entry pipelineCleanupEntry) error { + if entry.Docker != c.Identity || !validPipelineCleanupLabel(entry.Label) { + return fmt.Errorf("pipeline Docker cleanup identity mismatch") + } + ids := make(map[string]bool) + for _, id := range entry.IDs { + if !validPipelineDockerID(id) || ids[id] { + return fmt.Errorf("pipeline Docker journaled ID invalid") + } + ids[id] = true + } + ctx, cancel := context.WithTimeout(ctx, pipelineDockerCleanupTimeout) + defer cancel() + filter := "label=" + pipelineDockerCleanupLabelKey + "=" + entry.Label + labelIDs, err := c.list(ctx, filter) + if err != nil { + return err + } + for _, id := range labelIDs { + ids[id] = true + } + all := make([]string, 0, len(ids)) + for id := range ids { + all = append(all, id) + } + slices.Sort(all) + var present []string + for _, id := range all { + found, err := c.listID(ctx, id) + if err != nil { + return err + } + if len(found) != 0 { + if err := c.verifyContainer(ctx, id, entry.Label); err != nil { + return err + } + present = append(present, id) + } + } + // Validate all ownership before removing any ID, including journaled IDs + // that no longer match the label query. Labels on a full container ID are immutable. + for _, id := range present { + if _, err := c.Run(ctx, "rm", "--force", "--", id); err != nil { + return err + } + } + remaining, err := c.list(ctx, filter) + if err != nil { + return err + } + if len(remaining) != 0 { + return fmt.Errorf("pipeline Docker cleanup label readback nonempty") + } + for _, id := range all { + remaining, err := c.listID(ctx, id) + if err != nil { + return err + } + if len(remaining) != 0 { + return fmt.Errorf("pipeline Docker cleanup ID readback nonempty") + } + } + return nil +} + +// CreateArgs returns arguments after 'create', for RunInProcessGroup. +func (c *pipelineDockerClient) CreateArgs(cfg sandbox.SandboxConfig, command []string, label, cidfile string) ([]string, error) { + if err := sandbox.ValidateRecordConfig(cfg); err != nil { + return nil, err + } + if !validPipelineCleanupLabel(label) || !filepath.IsAbs(cidfile) || filepath.Clean(cidfile) != cidfile || + strings.ContainsAny(cidfile, "\x00\r\n") || !strings.HasPrefix(filepath.Base(cidfile), label+"-") || !strings.HasSuffix(cidfile, ".cid") { + return nil, fmt.Errorf("pipeline Docker cleanup label or CID path invalid") + } + if len(command) == 0 || command[0] == "" { + return nil, fmt.Errorf("pipeline Docker command required") + } + for _, arg := range command { + if strings.ContainsRune(arg, '\x00') { + return nil, fmt.Errorf("pipeline Docker command invalid") + } + } + args := []string{"--label", pipelineDockerCleanupLabelKey + "=" + label, "--cidfile", cidfile, + "--user", cfg.User, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", + "--pids-limit", strconv.FormatInt(cfg.PidsLimit, 10), "--memory", strconv.FormatInt(cfg.MemoryLimit, 10), + "--cpus", strconv.FormatFloat(cfg.CPULimit, 'f', -1, 64), "--network", cfg.NetworkMode} + if cfg.WorkDir != "" { + args = append(args, "--workdir", cfg.WorkDir) + } + keys := make([]string, 0, len(cfg.Env)) + for key := range cfg.Env { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + args = append(args, "--env", key+"="+cfg.Env[key]) + } + keys = keys[:0] + for key := range cfg.Tmpfs { + keys = append(keys, key) + } + slices.Sort(keys) + for _, key := range keys { + args = append(args, "--tmpfs", key+":"+cfg.Tmpfs[key]) + } + args = append(args, "--", cfg.Image) + return append(args, command...), nil +} + +func (c *pipelineDockerClient) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + c.closed = true + if c.configDir != "" { + if err := os.RemoveAll(c.configDir); err != nil { + return fmt.Errorf("pipeline Docker private config cleanup failed") + } + } + return nil +} diff --git a/cmd/wfctl/pipeline_docker_unix_test.go b/cmd/wfctl/pipeline_docker_unix_test.go new file mode 100644 index 00000000..3d13ad5b --- /dev/null +++ b/cmd/wfctl/pipeline_docker_unix_test.go @@ -0,0 +1,1011 @@ +//go:build linux || darwin + +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/sandbox" + "golang.org/x/sys/unix" +) + +const pipelineDockerTestLabel = "wfctl-0123456789abcdef0123456789abcdef" + +type pipelineDockerFixture struct { + Context string `json:"context"` + ServerID string `json:"server_id"` + Containers map[string]string `json:"containers"` + Fail string `json:"fail"` + Sleep string `json:"sleep"` + Flood string `json:"flood"` + Keep bool `json:"keep"` + ListOverride string `json:"list_override"` + InspectOverride string `json:"inspect_override"` + UnavailableHost string `json:"unavailable_host"` + Helper string `json:"helper"` + HelperBlocks bool `json:"helper_blocks"` + Group pipelineProcessIdentity `json:"group"` + LateCreate bool `json:"late_create"` +} + +type pipelineDockerInvocation struct { + Args []string `json:"args"` + Env []string `json:"env"` + Files map[string]string `json:"files"` + Config string `json:"config"` + PID int `json:"pid"` + PGID int `json:"pgid"` +} + +// The helper substitutes only the Docker CLI dependency. Resolution, execution, +// capture, identity verification, argument building, and cleanup are real code. +func TestPipelineDockerCLIHelper(t *testing.T) { + if slices.Contains(os.Args, "--pipeline-docker-group-leader") { + time.Sleep(20 * time.Second) + os.Exit(0) + } + if marker := slices.Index(os.Args, "--pipeline-docker-late-create"); marker >= 0 { + time.Sleep(2 * time.Second) + _ = os.WriteFile(filepath.Join(os.Args[marker+1], "late-create"), []byte("created after empty probe"), 0600) + os.Exit(0) + } + if marker := slices.Index(os.Args, "--pipeline-docker-transport-parent"); marker >= 0 { + dir := os.Args[marker+1] + data, _ := os.ReadFile(filepath.Join(dir, "fixture.json")) + var fixture pipelineDockerFixture + _ = json.Unmarshal(data, &fixture) + client, err := resolvePipelineDockerClient(context.Background()) + if err != nil { + os.Exit(96) + } + defer client.Close() + _, _ = client.RunInProcessGroup(context.Background(), fixture.Group, "create", "--label", "wfctl.pipeline.cleanup="+pipelineDockerTestLabel, "--", "alpine:latest", "true") + os.Exit(0) + } + if slices.Contains(os.Args, "--pipeline-docker-inherited-fd") { + time.Sleep(20 * time.Second) + os.Exit(0) + } + marker := slices.Index(os.Args, "--pipeline-docker-fixture") + if marker < 0 { + return + } + dir, args := os.Args[marker+1], os.Args[marker+2:] + data, err := os.ReadFile(filepath.Join(dir, "fixture.json")) + if err != nil { + os.Exit(90) + } + var fixture pipelineDockerFixture + if json.Unmarshal(data, &fixture) != nil { + os.Exit(91) + } + invocation := pipelineDockerInvocation{Args: args, Env: os.Environ(), Files: map[string]string{}} + invocation.PID, invocation.PGID = os.Getpid(), syscall.Getpgrp() + commandArgs := args + host := "unix:///daemon-b.sock" + for len(commandArgs) > 0 && strings.HasPrefix(commandArgs[0], "--") { + flag, value, hasValue := strings.Cut(commandArgs[0], "=") + commandArgs = commandArgs[1:] + if !hasValue { + if len(commandArgs) == 0 { + os.Exit(92) + } + value, commandArgs = commandArgs[0], commandArgs[1:] + } + switch flag { + case "--host": + host = value + case "--config": + config, _ := os.ReadFile(filepath.Join(value, "config.json")) + invocation.Config = string(config) + case "--tlscacert", "--tlscert", "--tlskey": + material, _ := os.ReadFile(value) + invocation.Files[flag] = string(material) + } + } + log, _ := os.OpenFile(filepath.Join(dir, "calls.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + _ = json.NewEncoder(log).Encode(invocation) + _ = log.Close() + if len(commandArgs) == 0 { + os.Exit(93) + } + command := commandArgs[0] + if fixture.Helper == command { + binary, _ := os.Executable() + helperArgs := []string{"-test.run=^TestPipelineDockerCLIHelper$", "--", "--pipeline-docker-inherited-fd"} + if fixture.LateCreate { + helperArgs = []string{"-test.run=^TestPipelineDockerCLIHelper$", "--", "--pipeline-docker-late-create", dir} + } + helper := exec.Command(binary, helperArgs...) + helper.Stdout, helper.Stderr, helper.Env = os.Stdout, os.Stderr, os.Environ() + if helper.Start() != nil { + os.Exit(95) + } + _ = os.WriteFile(filepath.Join(dir, "helper.pid"), []byte(fmt.Sprint(helper.Process.Pid)), 0600) + if fixture.HelperBlocks { + time.Sleep(20 * time.Second) + } + } + if command != "context" && host == fixture.UnavailableHost { + fmt.Fprint(os.Stderr, "unavailable daemon credential=TEST_SECRET_TOKEN") + os.Exit(7) + } + if fixture.Sleep == command { + time.Sleep(20 * time.Second) + } + if fixture.Fail == command { + fmt.Fprint(os.Stderr, "credential=TEST_SECRET_TOKEN raw daemon stderr") + os.Exit(7) + } + if fixture.Flood == command { + fmt.Fprint(os.Stdout, strings.Repeat("x", sandbox.MaxOutputBytes+1)) + fmt.Fprint(os.Stderr, strings.Repeat("y", sandbox.MaxOutputBytes+1)) + os.Exit(0) + } + switch command { + case "context": + fmt.Print(fixture.Context) + case "info": + _ = json.NewEncoder(os.Stdout).Encode(fixture.ServerID) + case "ps": + if fixture.ListOverride != "" { + fmt.Print(fixture.ListOverride) + break + } + filter := "" + for i, arg := range commandArgs { + if arg == "--filter" && i+1 < len(commandArgs) { + filter = commandArgs[i+1] + } + } + for id, label := range fixture.Containers { + if filter == "id="+id || filter == "label=wfctl.pipeline.cleanup="+label { + fmt.Println(id) + } + } + case "inspect": + if fixture.InspectOverride != "" { + fmt.Print(fixture.InspectOverride) + break + } + id := commandArgs[len(commandArgs)-1] + label, ok := fixture.Containers[id] + if !ok { + os.Exit(1) + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"id": id, "labels": map[string]string{"wfctl.pipeline.cleanup": label}}) + case "rm": + if !fixture.Keep { + delete(fixture.Containers, commandArgs[len(commandArgs)-1]) + data, _ = json.Marshal(fixture) + _ = os.WriteFile(filepath.Join(dir, "fixture.json"), data, 0600) + } + case "wait": + fmt.Println("0") + case "create": + id := strings.Repeat("a", 64) + for i, arg := range commandArgs { + if arg == "--cidfile" && i+1 < len(commandArgs) { + file, err := os.Create(commandArgs[i+1]) + if err != nil { + os.Exit(97) + } + if _, err := file.WriteString(id); err != nil { + os.Exit(98) + } + if file.Close() != nil { + os.Exit(99) + } + break + } + } + fmt.Println(id) + case "start", "attach": + fmt.Print("captured output") + default: + os.Exit(94) + } + os.Exit(0) +} + +func newPipelineDockerFixture(t *testing.T) (string, *pipelineDockerFixture) { + t.Helper() + dir := t.TempDir() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + quote := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } + // Preserve outer race policy; exit sleep must not consume the fault deadline. + wrapper := "#!/bin/sh\nexport GORACE=" + quote(os.Getenv("GORACE")+" atexit_sleep_ms=0") + "\nexec " + quote(binary) + " -test.run=^TestPipelineDockerCLIHelper$ -- --pipeline-docker-fixture " + quote(dir) + " \"$@\"\n" + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(wrapper), 0700); err != nil { + t.Fatal(err) + } + for _, key := range []string{"DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_CONFIG"} { + t.Setenv(key, "") + } + t.Setenv("PATH", dir+":"+os.Getenv("PATH")) + t.Setenv("HOME", dir) + fixture := &pipelineDockerFixture{ + Context: `[{"Name":"default","Endpoints":{"docker":{"Host":"unix:///daemon-a.sock","SkipTLSVerify":false}},"TLSMaterial":{},"Storage":{}}]`, + ServerID: "daemon-A", + Containers: map[string]string{}, + } + writePipelineDockerFixture(t, dir, fixture) + return dir, fixture +} + +func writePipelineDockerFixture(t *testing.T, dir string, fixture *pipelineDockerFixture) { + t.Helper() + data, err := json.Marshal(fixture) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "fixture.json"), data, 0600); err != nil { + t.Fatal(err) + } +} + +func pipelineDockerCalls(t *testing.T, dir string) []pipelineDockerInvocation { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "calls.jsonl")) + if err != nil { + t.Fatal(err) + } + var calls []pipelineDockerInvocation + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + var call pipelineDockerInvocation + if err := json.Unmarshal([]byte(line), &call); err != nil { + t.Fatal(err) + } + calls = append(calls, call) + } + return calls +} + +func resolvePipelineDockerFixture(t *testing.T) *pipelineDockerClient { + t.Helper() + client, err := resolvePipelineDockerClient(context.Background()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestPipelineDockerFixturePreservesRaceOptions(t *testing.T) { + const policy = "exitcode=87 halt_on_error=1 strip_path_prefix=/task35/report-policy atexit_sleep_ms=1000" + t.Setenv("GORACE", policy) + dir, _ := newPipelineDockerFixture(t) + t.Setenv("GORACE", "exitcode=99") + _ = resolvePipelineDockerFixture(t) + want := "GORACE=" + policy + " atexit_sleep_ms=0" + for _, call := range pipelineDockerCalls(t, dir) { + if !slices.Contains(call.Env, want) { + t.Fatalf("fake CLI lost captured race policy: want %q", want) + } + } +} + +func TestPipelineDockerPinnedAmbient(t *testing.T) { + dir, _ := newPipelineDockerFixture(t) + t.Setenv("DOCKER_HOST", "unix:///daemon-a.sock") + t.Setenv("DOCKER_CUSTOM_HEADERS", "Authorization=TEST_SECRET_TOKEN") + t.Setenv("GITHUB_TOKEN", "TEST_SECRET_TOKEN") + t.Setenv("HTTPS_PROXY", "https://TEST_SECRET_TOKEN@proxy.invalid") + if err := os.MkdirAll(filepath.Join(dir, ".docker"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".docker", "config.json"), []byte(`{"auths":{"registry":{"auth":"TEST_SECRET_TOKEN"}},"proxies":{"default":{"httpProxy":"TEST_SECRET_TOKEN"}}}`), 0600); err != nil { + t.Fatal(err) + } + client := resolvePipelineDockerFixture(t) + identity := client.Identity + if identity.Endpoint != "unix:///daemon-a.sock" || identity.ServerID != "daemon-A" || len(identity.TLSHash) != 64 { + t.Fatalf("identity was not pinned: %+v", identity) + } + t.Setenv("DOCKER_HOST", "unix:///daemon-b.sock") + t.Setenv("DOCKER_CONTEXT", "daemon-b") + t.Setenv("DOCKER_TLS_VERIFY", "1") + t.Setenv("DOCKER_CONFIG", "/foreign/config") + t.Setenv("PATH", "/foreign/path") + result, err := client.Run(context.Background(), "wait", strings.Repeat("a", 64)) + if err != nil || result.Stdout != "0\n" || result.ExitCode != 0 { + t.Fatalf("pinned Run: result=%+v err=%v", result, err) + } + calls := pipelineDockerCalls(t, dir) + if slices.ContainsFunc(calls[1:], func(call pipelineDockerInvocation) bool { return slices.Contains(call.Args, "context") }) { + t.Fatal("context was resolved more than once") + } + for _, call := range calls[1:] { + if !slices.Contains(call.Args, identity.Endpoint) || call.Config != "{}\n" { + t.Fatalf("ambient transport/config used: %+v", call) + } + for _, env := range call.Env { + if strings.HasPrefix(env, "DOCKER_") || strings.Contains(env, "TEST_SECRET_TOKEN") || strings.HasPrefix(env, "HTTPS_PROXY=") { + t.Fatalf("ambient Docker/credential/proxy env inherited: %q", env) + } + } + } +} + +func TestPipelineDockerContextTLSMaterial(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + tlsPath := filepath.Join(dir, "context-tls") + if err := os.MkdirAll(filepath.Join(tlsPath, "docker"), 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"ca.pem", "cert.pem", "key.pem"} { + if err := os.WriteFile(filepath.Join(tlsPath, "docker", name), []byte("original-"+name), 0600); err != nil { + t.Fatal(err) + } + } + metadata := []any{map[string]any{ + "Name": "remote", "Endpoints": map[string]any{"docker": map[string]any{"Host": "tcp://daemon.example:2376", "SkipTLSVerify": false}}, + "TLSMaterial": map[string]any{"docker": []string{"ca.pem", "cert.pem", "key.pem"}}, "Storage": map[string]any{"TLSPath": tlsPath}, + }} + data, _ := json.Marshal(metadata) + fixture.Context = string(data) + writePipelineDockerFixture(t, dir, fixture) + t.Setenv("DOCKER_CONTEXT", "remote") + t.Setenv("DOCKER_HOST", "unix:///ignored.sock") + client := resolvePipelineDockerFixture(t) + originalIdentity := client.Identity + if again := resolvePipelineDockerFixture(t); again.Identity != originalIdentity { + t.Fatal("identity digest depends on temporary TLS file paths") + } + if err := os.WriteFile(filepath.Join(tlsPath, "docker", "key.pem"), []byte("changed-key.pem"), 0600); err != nil { + t.Fatal(err) + } + if _, err := client.Run(context.Background(), "wait", strings.Repeat("a", 64)); err != nil { + t.Fatal(err) + } + for _, call := range pipelineDockerCalls(t, dir)[1:] { + if slices.Contains(call.Args, "context") { + continue + } + if call.Files["--tlskey"] != "original-key.pem" || !slices.Contains(call.Args, "--tlsverify=true") { + t.Fatalf("TLS material was not snapshotted: %+v", call) + } + } + changed := resolvePipelineDockerFixture(t) + if changed.Identity.TLSHash == originalIdentity.TLSHash { + t.Fatal("TLS bytes change did not change identity") + } + entry := pipelineCleanupEntry{Label: pipelineDockerTestLabel, Docker: originalIdentity} + before := len(pipelineDockerCalls(t, dir)) + if err := changed.Cleanup(context.Background(), entry); err == nil { + t.Fatal("TLS identity mismatch did not fail closed") + } + if len(pipelineDockerCalls(t, dir)) != before { + t.Fatal("mismatched cleanup contacted daemon") + } +} + +func TestPipelineDockerResolutionFailClosed(t *testing.T) { + for _, test := range []struct{ name, context, server, fail string }{ + {"info failure", "", "daemon-A", "info"}, + {"missing server ID", "", "", ""}, + {"unsafe server ID", "", "ID\nTEST_SECRET_TOKEN", ""}, + {"malformed context", "TEST_SECRET_TOKEN", "daemon-A", ""}, + {"SSH unsupported", `[{"Name":"ssh","Endpoints":{"docker":{"Host":"ssh://user@remote"}}}]`, "daemon-A", ""}, + {"endpoint credentials", `[{"Endpoints":{"docker":{"Host":"tcp://user:TEST_SECRET_TOKEN@remote:2376"}}}]`, "daemon-A", ""}, + } { + t.Run(test.name, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + fixture.ServerID, fixture.Fail = test.server, test.fail + if test.context != "" { + fixture.Context = test.context + } + writePipelineDockerFixture(t, dir, fixture) + client, err := resolvePipelineDockerClient(context.Background()) + if client != nil { + _ = client.Close() + } + if err == nil || strings.Contains(err.Error(), "TEST_SECRET_TOKEN") || strings.Contains(err.Error(), "raw daemon stderr") { + t.Fatalf("resolution must fail with redacted error: %v", err) + } + if test.name == "SSH unsupported" && !strings.Contains(err.Error(), "SSH") { + t.Fatalf("unsupported SSH requires explicit message: %v", err) + } + }) + } +} + +func TestPipelineDockerRunFaults(t *testing.T) { + for _, fault := range []string{"daemon drift", "info failure", "operation failure", "timeout", "overflow", "TLS snapshot drift", "identity field drift", "transport override", "closed"} { + t.Run(fault, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + client := resolvePipelineDockerFixture(t) + args := []string{"wait", strings.Repeat("a", 64)} + ctx := context.Background() + switch fault { + case "daemon drift": + fixture.ServerID = "daemon-B" + case "info failure": + fixture.Fail = "info" + case "operation failure": + fixture.Fail = "wait" + case "timeout": + fixture.Sleep = "wait" + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 150*time.Millisecond) + defer cancel() + case "overflow": + fixture.Flood = "wait" + case "TLS snapshot drift": + if err := os.WriteFile(filepath.Join(client.configDir, "config.json"), []byte(`{"proxies":{"default":{"httpProxy":"TEST_SECRET_TOKEN"}}}`), 0600); err != nil { + t.Fatal(err) + } + case "identity field drift": + client.Identity.ServerID = "daemon-B" + case "transport override": + args = []string{"wait", "--host=unix:///daemon-b.sock", strings.Repeat("a", 64)} + case "closed": + if err := client.Close(); err != nil { + t.Fatal(err) + } + } + writePipelineDockerFixture(t, dir, fixture) + started := time.Now() + result, err := client.Run(ctx, args...) + if err == nil || strings.Contains(err.Error(), "TEST_SECRET_TOKEN") || strings.Contains(err.Error(), "raw daemon stderr") { + t.Fatalf("fault must fail with redacted error: %v", err) + } + if fault == "timeout" && time.Since(started) > 2*time.Second { + t.Fatal("Docker timeout was not bounded") + } + if fault == "overflow" && (result == nil || len(result.Stdout) != sandbox.MaxOutputBytes || len(result.Stderr) != sandbox.MaxOutputBytes) { + t.Fatalf("output not bounded: %+v", result) + } + if fault == "daemon drift" || fault == "info failure" { + for _, call := range pipelineDockerCalls(t, dir) { + if slices.Contains(call.Args, "wait") { + t.Fatal("operation ran without matching daemon identity") + } + } + } + }) + } +} + +func TestPipelineDockerRunRejectsNonFullIDs(t *testing.T) { + dir, _ := newPipelineDockerFixture(t) + client := resolvePipelineDockerFixture(t) + for _, command := range []string{"start", "attach", "wait", "inspect", "rm"} { + for _, id := range []string{"foreign-name", strings.Repeat("a", 12), strings.Repeat("A", 64), strings.Repeat("g", 64)} { + before := len(pipelineDockerCalls(t, dir)) + if _, err := client.Run(context.Background(), command, id); err == nil { + t.Errorf("%s accepted non-full ID", command) + } + if len(pipelineDockerCalls(t, dir)) != before { + t.Errorf("%s contacted daemon with non-full ID", command) + } + } + } +} + +func TestPipelineDockerCleanupBeforeCIDJournaled(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + id := strings.Repeat("a", 64) + fixture.Containers[id] = pipelineDockerTestLabel + fixture.Containers[strings.Repeat("b", 64)] = "foreign" + writePipelineDockerFixture(t, dir, fixture) + client := resolvePipelineDockerFixture(t) + entry := pipelineCleanupEntry{Label: pipelineDockerTestLabel, Docker: client.Identity} + cidfile := filepath.Join(dir, pipelineDockerTestLabel+"-1.cid") + if err := os.WriteFile(cidfile, []byte(id+"\n"), 0600); err != nil { + t.Fatal(err) + } + entry.CIDFiles = []string{filepath.Base(cidfile)} + if err := client.Cleanup(context.Background(), entry); err != nil { + t.Fatal(err) + } + calls := pipelineDockerCalls(t, dir) + var removed []string + for _, call := range calls { + if slices.Contains(call.Args, "rm") { + removed = append(removed, call.Args[len(call.Args)-1]) + } + } + if !slices.Equal(removed, []string{id}) { + t.Fatalf("cleanup did not remove exact pre-CID label match: %v", removed) + } + if data, err := os.ReadFile(cidfile); err != nil || string(data) != id+"\n" { + t.Fatal("Docker cleanup must leave CID files for the parent") + } + if err := client.Cleanup(context.Background(), entry); err != nil { + t.Fatalf("empty readback cleanup must be idempotent: %v", err) + } +} + +func TestPipelineDockerUnavailableDaemonRetainsForRetry(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + id := strings.Repeat("a", 64) + fixture.Containers[id] = pipelineDockerTestLabel + writePipelineDockerFixture(t, dir, fixture) + client := resolvePipelineDockerFixture(t) + entry := pipelineCleanupEntry{Label: pipelineDockerTestLabel, Docker: client.Identity, IDs: []string{id}} + fixture.UnavailableHost = "unix:///daemon-a.sock" + writePipelineDockerFixture(t, dir, fixture) + t.Setenv("DOCKER_HOST", "unix:///daemon-b.sock") + if err := client.Cleanup(context.Background(), entry); err == nil { + t.Fatal("daemon A unavailable must not be declared empty using ambient B") + } + for _, call := range pipelineDockerCalls(t, dir)[1:] { + if !slices.Contains(call.Args, client.Identity.Endpoint) || slices.Contains(call.Args, "rm") { + t.Fatal("unavailable daemon failed closed without retaining exact identity") + } + } + fixture.UnavailableHost = "" + writePipelineDockerFixture(t, dir, fixture) + if err := client.Cleanup(context.Background(), entry); err != nil { + t.Fatalf("same daemon after restart must reconcile retained entry: %v", err) + } +} + +func TestPipelineDockerCleanupCallCancellation(t *testing.T) { + for _, command := range []string{"info", "ps", "inspect", "rm"} { + t.Run(command, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + id := strings.Repeat("a", 64) + fixture.Containers[id] = pipelineDockerTestLabel + writePipelineDockerFixture(t, dir, fixture) + client := resolvePipelineDockerFixture(t) + fixture.Sleep = command + writePipelineDockerFixture(t, dir, fixture) + ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond) + defer cancel() + started := time.Now() + if err := client.Cleanup(ctx, pipelineCleanupEntry{Label: pipelineDockerTestLabel, Docker: client.Identity}); err == nil { + t.Fatal("blocked cleanup call must fail closed") + } + if time.Since(started) > 2*time.Second { + t.Fatal("blocked cleanup call exceeded deadline") + } + }) + } +} + +func TestPipelineDockerEnvironmentTLS(t *testing.T) { + for _, verify := range []bool{false, true} { + t.Run(fmt.Sprint(verify), func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + fixture.Context = `[{"Name":"default","Endpoints":{"docker":{"Host":"tcp://daemon.example:2376","SkipTLSVerify":false}},"TLSMaterial":{},"Storage":{"TLSPath":""}}]` + writePipelineDockerFixture(t, dir, fixture) + certs := filepath.Join(dir, "certs") + if err := os.Mkdir(certs, 0700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"ca.pem", "cert.pem", "key.pem"} { + if err := os.WriteFile(filepath.Join(certs, name), []byte("env-"+name), 0600); err != nil { + t.Fatal(err) + } + } + t.Setenv("DOCKER_CERT_PATH", certs) + t.Setenv("DOCKER_TLS", "1") + if verify { + t.Setenv("DOCKER_TLS_VERIFY", "1") + } + client := resolvePipelineDockerFixture(t) + for _, call := range pipelineDockerCalls(t, dir)[1:] { + if call.Files["--tlskey"] != "env-key.pem" || !slices.Contains(call.Args, "--tlsverify="+fmt.Sprint(verify)) { + t.Fatalf("effective TLS env not explicitly pinned: %+v", call) + } + } + if err := os.WriteFile(filepath.Join(client.configDir, "key.pem"), []byte("modified-key"), 0600); err != nil { + t.Fatal(err) + } + if _, err := client.Run(context.Background(), "wait", strings.Repeat("a", 64)); err == nil { + t.Fatal("private TLS digest drift must fail closed") + } + }) + } +} + +func TestPipelineDockerCIDFilePrivateWithoutChangingCallerUmask(t *testing.T) { + marker := slices.Index(os.Args, "--pipeline-docker-cid-umask") + if marker < 0 { + dir, fixture := newPipelineDockerFixture(t) + fixture.Group = newPipelineDockerProcessGroup(t) + writePipelineDockerFixture(t, dir, fixture) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + // Control only the fixture subprocess's umask, never the Go test host's. + child := exec.CommandContext(ctx, "/bin/sh", "-c", `umask 022; exec "$@"`, "wfctl-umask-fixture", binary, + "-test.run=^TestPipelineDockerCIDFilePrivateWithoutChangingCallerUmask$", "--", "--pipeline-docker-cid-umask", dir) + child.WaitDelay = 250 * time.Millisecond + if output, err := child.CombinedOutput(); err != nil { + t.Fatalf("controlled-umask client regression: %v\n%s", err, output) + } + return + } + dir := os.Args[marker+1] + data, err := os.ReadFile(filepath.Join(dir, "fixture.json")) + if err != nil { + t.Fatal(err) + } + var fixture pipelineDockerFixture + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + client := resolvePipelineDockerFixture(t) + sentinel := filepath.Join(dir, "shell-evaluation") + literal := "literal ' \" ; $(touch " + sentinel + ") `touch " + sentinel + "`\n--host=not-a-flag" + command := []string{"printf", "%s", literal} + cfg := sandbox.DefaultSecureSandboxConfig("alpine:latest") + cfg.Env = map[string]string{"LITERAL": literal} + cidfile := filepath.Join(dir, pipelineDockerTestLabel+"-literal '$.cid") + args, err := client.CreateArgs(cfg, command, pipelineDockerTestLabel, cidfile) + if err != nil { + t.Fatal(err) + } + created, err := client.RunInProcessGroup(context.Background(), fixture.Group, append([]string{"create"}, args...)...) + if err != nil { + t.Fatal(err) + } + id, err := os.ReadFile(cidfile) + if err != nil || string(id) != strings.TrimSpace(created.Stdout) || !validPipelineDockerID(string(id)) { + t.Fatalf("CID file/output mismatch: %q, %v", id, err) + } + // os.Create here must still observe the caller's original umask after Run. + probe, err := os.Create(filepath.Join(dir, "caller-umask")) + if err != nil { + t.Fatal(err) + } + if err := probe.Close(); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(probe.Name()); err != nil || info.Mode().Perm() != 0644 { + t.Fatalf("Docker execution changed caller umask: %v, %v", info, err) + } + if _, err := os.Stat(sentinel); !os.IsNotExist(err) { + t.Fatal("Docker argument evaluated as shell code") + } + calls := pipelineDockerCalls(t, dir) + for _, call := range calls[2:] { + if call.PGID != fixture.Group.PGID { + t.Fatal("CID-file execution or server-ID probe escaped recorded group") + } + } + last := calls[len(calls)-1] + if !slices.Equal(last.Args[len(last.Args)-len(args):], args) { + t.Fatalf("wrapper changed literal arguments: %v", last.Args) + } + info, err := os.Stat(cidfile) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatalf("Docker os.Create CID file mode = %04o; want 0600", info.Mode().Perm()) + } + if err := validatePipelinePrivateFile(info, 0600, false); err != nil { + t.Fatalf("CID file rejected by unchanged custody policy: %v", err) + } +} + +func TestPipelineDockerCreateStartWaitUsesPinnedClient(t *testing.T) { + dir, _ := newPipelineDockerFixture(t) + process := newPipelineDockerProcessGroup(t) + client := resolvePipelineDockerFixture(t) + cfg := sandbox.DefaultSecureSandboxConfig("alpine:latest") + args, err := client.CreateArgs(cfg, []string{"sh", "-euc", "echo literal --host=unchanged"}, pipelineDockerTestLabel, filepath.Join(dir, pipelineDockerTestLabel+"-1.cid")) + if err != nil { + t.Fatal(err) + } + created, err := client.RunInProcessGroup(context.Background(), process, append([]string{"create"}, args...)...) + if err != nil { + t.Fatal(err) + } + id := strings.TrimSpace(created.Stdout) + if !validPipelineDockerID(id) { + t.Fatal("create did not return a full container ID") + } + started, err := client.RunInProcessGroup(context.Background(), process, "start", "-a", id) + if err != nil || started.Stdout != "captured output" { + t.Fatalf("start capture: %+v, %v", started, err) + } + waited, err := client.RunInProcessGroup(context.Background(), process, "wait", id) + if err != nil || waited.Stdout != "0\n" { + t.Fatalf("wait capture: %+v, %v", waited, err) + } + for _, call := range pipelineDockerCalls(t, dir)[2:] { + if call.PGID != process.PGID { + t.Fatalf("broker command/server-ID probe escaped recorded group: %+v", call) + } + } +} + +func newPipelineDockerProcessGroup(t *testing.T) pipelineProcessIdentity { + t.Helper() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + leader := exec.Command(binary, "-test.run=^TestPipelineDockerCLIHelper$", "--", "--pipeline-docker-group-leader") + leader.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := leader.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = unix.Kill(-leader.Process.Pid, unix.SIGKILL) + _ = leader.Wait() + }) + token, err := pipelineProcessStartToken(leader.Process.Pid) + if err != nil { + t.Fatal(err) + } + return pipelineProcessIdentity{PID: leader.Process.Pid, PGID: leader.Process.Pid, Start: token} +} + +func TestPipelineDockerProcessGroupIdentityFailsClosed(t *testing.T) { + dir, _ := newPipelineDockerFixture(t) + process := newPipelineDockerProcessGroup(t) + client := resolvePipelineDockerFixture(t) + for _, bad := range []pipelineProcessIdentity{ + {}, {PID: process.PID, PGID: process.PGID, Start: "reused"}, + {PID: process.PID, PGID: process.PGID + 1, Start: process.Start}, + {PID: process.PID, PGID: process.PGID}, + } { + before := len(pipelineDockerCalls(t, dir)) + if _, err := client.RunInProcessGroup(context.Background(), bad, "wait", strings.Repeat("a", 64)); err == nil { + t.Fatal("unverified recorded process group accepted") + } + if len(pipelineDockerCalls(t, dir)) != before { + t.Fatal("invalid process group contacted daemon") + } + } + if _, err := client.Run(context.Background(), "create", "--", "alpine:latest", "true"); err == nil { + t.Fatal("unjournaled process group retained Docker create authority") + } + _ = unix.Kill(-process.PGID, unix.SIGKILL) + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + active, err := pipelineProcessGroupActive(process.PGID) + if err == nil && !active { + break + } + time.Sleep(10 * time.Millisecond) + } + if _, err := client.RunInProcessGroup(context.Background(), process, "wait", strings.Repeat("a", 64)); err == nil { + t.Fatal("dead recorded group accepted") + } +} + +func TestPipelineDockerGroupCancellationLeavesRootCustody(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + process := newPipelineDockerProcessGroup(t) + client := resolvePipelineDockerFixture(t) + fixture.Helper, fixture.HelperBlocks = "wait", true + writePipelineDockerFixture(t, dir, fixture) + ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond) + defer cancel() + started := time.Now() + if _, err := client.RunInProcessGroup(ctx, process, "wait", strings.Repeat("a", 64)); err == nil { + t.Fatal("group-bound blocked CLI must fail at its deadline") + } + if time.Since(started) > 2*time.Second { + t.Fatal("group-bound inherited FD exceeded bounded wait") + } + if token, err := pipelineProcessStartToken(process.PID); err != nil || token != process.Start { + t.Fatal("CLI cancellation killed the root-owned recorded group") + } + data, err := os.ReadFile(filepath.Join(dir, "helper.pid")) + if err != nil { + t.Fatal("CLI helper was not launched") + } + var pid int + _, _ = fmt.Sscan(string(data), &pid) + if group, err := unix.Getpgid(pid); err != nil || group != process.PGID { + t.Fatal("helper did not remain in recorded root custody") + } +} + +func TestPipelineDockerParentSIGKILLStopsLateCreateBeforeEmptyProbe(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + t.Setenv("TMPDIR", dir) + process := newPipelineDockerProcessGroup(t) + client := resolvePipelineDockerFixture(t) + fixture.Group, fixture.Helper, fixture.HelperBlocks, fixture.LateCreate = process, "create", true, true + writePipelineDockerFixture(t, dir, fixture) + binary, _ := os.Executable() + parent := exec.Command(binary, "-test.run=^TestPipelineDockerCLIHelper$", "--", "--pipeline-docker-transport-parent", dir) + if err := parent.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = parent.Process.Kill() + _ = parent.Wait() + }) + var helperPID int + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(filepath.Join(dir, "helper.pid")); err == nil { + _, _ = fmt.Sscan(string(data), &helperPID) + break + } + time.Sleep(10 * time.Millisecond) + } + if helperPID <= 0 { + t.Fatal("parent never launched the late-create helper") + } + for _, call := range pipelineDockerCalls(t, dir) { + if slices.Contains(call.Args, "create") { + t.Cleanup(func() { _ = unix.Kill(call.PID, unix.SIGKILL) }) + } + } + t.Cleanup(func() { _ = unix.Kill(helperPID, unix.SIGKILL) }) + if err := parent.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = parent.Wait() + group, err := unix.Getpgid(helperPID) + if err != nil || group != process.PGID { + t.Fatal("parent SIGKILL left a late-create helper outside recorded custody") + } + // Restart reconciliation kills the recorded group before its empty probe. + if err := unix.Kill(-process.PGID, unix.SIGKILL); err != nil { + t.Fatal(err) + } + deadline = time.Now().Add(time.Second) + for time.Now().Before(deadline) { + active, err := pipelineProcessGroupActive(process.PGID) + if err == nil && !active { + break + } + time.Sleep(10 * time.Millisecond) + } + if active, err := pipelineProcessGroupActive(process.PGID); err != nil || active { + t.Fatal("recorded group was not empty before daemon readback") + } + if err := client.Cleanup(context.Background(), pipelineCleanupEntry{Label: pipelineDockerTestLabel, Docker: client.Identity}); err != nil { + t.Fatal(err) + } + time.Sleep(2100 * time.Millisecond) + if _, err := os.Stat(filepath.Join(dir, "late-create")); !os.IsNotExist(err) { + t.Fatal("create completed after empty label readback") + } +} + +func TestPipelineDockerInheritedFDDeadlineAndHelperCancellation(t *testing.T) { + for _, blocks := range []bool{false, true} { + t.Run(fmt.Sprint(blocks), func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + client := resolvePipelineDockerFixture(t) + fixture.Helper, fixture.HelperBlocks = "wait", blocks + writePipelineDockerFixture(t, dir, fixture) + ctx, cancel := context.WithTimeout(context.Background(), 1200*time.Millisecond) + defer cancel() + started := time.Now() + if _, err := client.Run(ctx, "wait", strings.Repeat("a", 64)); err == nil { + t.Fatal("inherited output FD must not appear as successful completion") + } + if time.Since(started) > 2*time.Second { + t.Fatal("inherited output FD exceeded bounded wait") + } + data, err := os.ReadFile(filepath.Join(dir, "helper.pid")) + if err != nil { + t.Fatalf("helper was not launched; deadline test did not exercise inherited FD: %v", err) + } + var pid int + if _, err := fmt.Sscan(string(data), &pid); err != nil || pid <= 0 { + t.Fatal("invalid helper PID") + } + t.Cleanup(func() { _ = unix.Kill(pid, unix.SIGKILL) }) + deadline := time.Now().Add(time.Second) + for unix.Kill(pid, 0) == nil && time.Now().Before(deadline) { + time.Sleep(10 * time.Millisecond) + } + if err := unix.Kill(pid, 0); err == nil { + t.Fatal("Docker helper survived bounded executor completion/cancellation") + } + }) + } +} + +func TestPipelineDockerCleanupRejectsAndRetains(t *testing.T) { + for _, fault := range []string{"endpoint drift", "TLS drift", "server drift", "foreign label", "short ID", "uppercase ID", "label prefix", "bad list", "inspect mismatch", "remove failure", "readback nonempty", "list failure", "inspect failure"} { + t.Run(fault, func(t *testing.T) { + dir, fixture := newPipelineDockerFixture(t) + id := strings.Repeat("a", 64) + fixture.Containers[id] = pipelineDockerTestLabel + writePipelineDockerFixture(t, dir, fixture) + client := resolvePipelineDockerFixture(t) + entry := pipelineCleanupEntry{Label: pipelineDockerTestLabel, Docker: client.Identity, IDs: []string{id}} + switch fault { + case "endpoint drift": + entry.Docker.Endpoint = "unix:///daemon-b.sock" + case "TLS drift": + entry.Docker.TLSHash = strings.Repeat("b", 64) + case "server drift": + entry.Docker.ServerID = "daemon-B" + case "foreign label": + fixture.Containers[id] = "foreign" + case "short ID": + entry.IDs = []string{id[:12]} + case "uppercase ID": + entry.IDs = []string{strings.ToUpper(id)} + case "label prefix": + entry.Label += "-foreign" + case "bad list": + fixture.ListOverride = id[:12] + "\n" + case "inspect mismatch": + fixture.InspectOverride = `{"id":"` + strings.Repeat("b", 64) + `","labels":{"wfctl.pipeline.cleanup":"` + pipelineDockerTestLabel + `"}}` + case "remove failure": + fixture.Fail = "rm" + case "readback nonempty": + fixture.Keep = true + case "list failure": + fixture.Fail = "ps" + case "inspect failure": + fixture.Fail = "inspect" + } + writePipelineDockerFixture(t, dir, fixture) + before, _ := json.Marshal(entry) + if err := client.Cleanup(context.Background(), entry); err == nil || strings.Contains(err.Error(), "TEST_SECRET_TOKEN") { + t.Fatalf("cleanup must fail closed: %v", err) + } + after, _ := json.Marshal(entry) + if string(before) != string(after) { + t.Fatal("failed cleanup changed journal identity") + } + if fault != "remove failure" && fault != "readback nonempty" { + for _, call := range pipelineDockerCalls(t, dir) { + if slices.Contains(call.Args, "rm") { + t.Fatal("unsafe removal before ownership/identity verification") + } + } + } + }) + } +} + +func TestPipelineDockerCreateArgs(t *testing.T) { + cfg := sandbox.DefaultSecureSandboxConfig("alpine:latest") + cfg.NetworkMode, cfg.WorkDir = "bridge", "/work" + cfg.Tmpfs["/work"] = "size=64m,mode=0700,uid=65532,gid=65532,noexec,nosuid,nodev" + cfg.Env = map[string]string{"Z": "explicit", "A": "--host=not-a-flag"} + client := &pipelineDockerClient{} + command := []string{"sh", "-euc", "printf '%s' --host=literal"} + cidfile := filepath.Join(t.TempDir(), pipelineDockerTestLabel+"-1.cid") + args, err := client.CreateArgs(cfg, command, pipelineDockerTestLabel, cidfile) + if err != nil { + t.Fatal(err) + } + want := []string{"--label", "wfctl.pipeline.cleanup=" + pipelineDockerTestLabel, "--cidfile", cidfile} + if !slices.Equal(args[:len(want)], want) { + t.Fatalf("ownership arguments missing: %v", args) + } + for _, arg := range []string{"65532:65532", "ALL", "no-new-privileges", "--read-only", "bridge", "A=--host=not-a-flag", "/work:size=64m,mode=0700,uid=65532,gid=65532,noexec,nosuid,nodev"} { + if !slices.Contains(args, arg) { + t.Fatalf("strict argument %q missing: %v", arg, args) + } + } + if slices.Index(args, "A=--host=not-a-flag") > slices.Index(args, "Z=explicit") || !slices.Equal(args[len(args)-len(command):], command) { + t.Fatal("env must be deterministic; command must remain literal") + } + if _, err := client.CreateArgs(cfg, command, "foreign", cidfile); err == nil { + t.Fatal("invalid cleanup label accepted") + } + if _, err := client.CreateArgs(cfg, command, pipelineDockerTestLabel, "/foreign.cid"); err == nil { + t.Fatal("unowned CID filename accepted") + } + cfg.Env["GITHUB_TOKEN"] = "TEST_SECRET_TOKEN" + if _, err := client.CreateArgs(cfg, command, pipelineDockerTestLabel, cidfile); err == nil || strings.Contains(err.Error(), "TEST_SECRET_TOKEN") { + t.Fatal("builder must apply the redacted record config policy") + } +} diff --git a/cmd/wfctl/pipeline_process_darwin.go b/cmd/wfctl/pipeline_process_darwin.go new file mode 100644 index 00000000..9641df7d --- /dev/null +++ b/cmd/wfctl/pipeline_process_darwin.go @@ -0,0 +1,47 @@ +package main + +import ( + "fmt" + "math" + + "golang.org/x/sys/unix" +) + +func pipelineProcessStartToken(pid int) (string, error) { + if pid <= 0 || pid > math.MaxInt32 { + return "", fmt.Errorf("invalid pipeline process ID") + } + processes, err := unix.SysctlKinfoProcSlice("kern.proc.pid", pid) + if err != nil { + return "", err + } + if len(processes) == 0 { + return "", unix.ESRCH + } + if len(processes) != 1 || processes[0].Proc.P_pid != int32(pid) { + return "", fmt.Errorf("invalid pipeline process identity") + } + proc := &processes[0] + return fmt.Sprintf("darwin:%d:%d", proc.Proc.P_starttime.Sec, proc.Proc.P_starttime.Usec), nil +} + +func pipelineProcessGroupActive(pgid int) (bool, error) { + return pipelineProcessGroupActiveExcept(pgid, 0) +} + +func pipelineProcessGroupActiveExcept(pgid, excludedPID int) (bool, error) { + if pgid <= 0 || pgid > math.MaxInt32 { + return false, fmt.Errorf("invalid pipeline process group ID") + } + processes, err := unix.SysctlKinfoProcSlice("kern.proc.all") + if err != nil { + return false, err + } + for i := range processes { + process := &processes[i] + if process.Eproc.Pgid == int32(pgid) && int(process.Proc.P_pid) != excludedPID && process.Proc.P_stat != 5 { + return true, nil + } + } + return false, nil +} diff --git a/cmd/wfctl/pipeline_process_darwin_test.go b/cmd/wfctl/pipeline_process_darwin_test.go new file mode 100644 index 00000000..abf9a27e --- /dev/null +++ b/cmd/wfctl/pipeline_process_darwin_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "errors" + "math" + "os" + "os/exec" + "syscall" + "testing" + + "golang.org/x/sys/unix" +) + +func TestPipelineDarwinReapedProcessIsAbsent(t *testing.T) { + if token, err := pipelineProcessStartToken(os.Getpid()); token == "" || err != nil { + t.Fatalf("live process identity was rejected: %q, %v", token, err) + } + child := exec.Command("/usr/bin/true") + if err := child.Run(); err != nil { + t.Fatal(err) + } + pid := child.Process.Pid + processes, err := unix.SysctlKinfoProcSlice("kern.proc.pid", pid) + if err != nil || len(processes) != 0 { + t.Fatalf("reaped PID %d did not yield an empty process census: %v, %v", pid, processes, err) + } + if token, err := pipelineProcessStartToken(pid); token != "" || !errors.Is(err, unix.ESRCH) { + t.Fatalf("reaped PID %d was not recognized as absent: %q, %v", pid, token, err) + } +} + +func TestPipelineDarwinProcessIdentityBounds(t *testing.T) { + if active, err := pipelineProcessGroupActive(syscall.Getpgrp()); err != nil || !active { + t.Fatalf("live process group was rejected: %v, %v", active, err) + } + for name, id := range map[string]int{ + "negative": -1, + "zero": 0, + "above int32": math.MaxInt32 + 1, + "below int32": math.MinInt32 - 1, + "wrapped process": 1<<32 + os.Getpid(), + "wrapped group": 1<<32 + syscall.Getpgrp(), + } { + t.Run(name, func(t *testing.T) { + if token, err := pipelineProcessStartToken(id); err == nil || token != "" { + t.Errorf("out-of-range PID %d returned a process identity: %q, %v", id, token, err) + } + if active, err := pipelineProcessGroupActive(id); err == nil || active { + t.Errorf("out-of-range PGID %d did not fail closed: %v, %v", id, active, err) + } + }) + } +} diff --git a/cmd/wfctl/pipeline_process_linux.go b/cmd/wfctl/pipeline_process_linux.go new file mode 100644 index 00000000..8b5fe9de --- /dev/null +++ b/cmd/wfctl/pipeline_process_linux.go @@ -0,0 +1,73 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" +) + +func pipelineProcessStartToken(pid int) (string, error) { + data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") + if err != nil { + return "", err + } + // comm may contain whitespace and parentheses; fields after its final ')' + // start with field 3 (state), not field 1 (pid). + end := strings.LastIndexByte(string(data), ')') + if end < 0 { + return "", fmt.Errorf("invalid process identity") + } + fields := strings.Fields(string(data[end+1:])) + if len(fields) <= 19 { + return "", fmt.Errorf("incomplete process identity") + } + if _, err := strconv.ParseUint(fields[19], 10, 64); err != nil { + return "", err + } + boot, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if err != nil { + return "", err + } + return "linux:" + strings.TrimSpace(string(boot)) + ":" + fields[19], nil +} + +func pipelineProcessGroupActive(pgid int) (bool, error) { + return pipelineProcessGroupActiveExcept(pgid, 0) +} + +func pipelineProcessGroupActiveExcept(pgid, excludedPID int) (bool, error) { + entries, err := os.ReadDir("/proc") + if err != nil { + return false, err + } + for _, entry := range entries { + if pid, err := strconv.Atoi(entry.Name()); err != nil || pid == excludedPID { + continue + } + data, err := os.ReadFile("/proc/" + entry.Name() + "/stat") + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return false, err + } + end := strings.LastIndexByte(string(data), ')') + if end < 0 { + return false, fmt.Errorf("invalid process census") + } + fields := strings.Fields(string(data[end+1:])) + if len(fields) < 3 { + return false, fmt.Errorf("invalid process census") + } + group, err := strconv.Atoi(fields[2]) + if err != nil { + return false, err + } + if group == pgid && fields[0] != "Z" && fields[0] != "X" { + return true, nil + } + } + return false, nil +} diff --git a/cmd/wfctl/pipeline_record.go b/cmd/wfctl/pipeline_record.go new file mode 100644 index 00000000..9149bd67 --- /dev/null +++ b/cmd/wfctl/pipeline_record.go @@ -0,0 +1,253 @@ +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/GoCodeAlone/workflow/module" +) + +const maxPipelineRecordBytes = 1 << 20 + +const pipelineRecordChildCommand = "__wfctl-pipeline-record-child-v1" + +var errPipelineRecordInvalid = errors.New("invalid pipeline record") + +type pipelineRecordOptions struct { + ConfigPath string + SuccessPrefix string + ErrorPrefix string +} + +func selectPipelineRecordEnvelope(envelope map[string]any) (map[string]any, string, error) { + status, _ := envelope["status"].(string) + if len(envelope) != 2 { + return nil, "", errPipelineRecordInvalid + } + switch status { + case "ok": + output, ok := envelope["output"].(map[string]any) + if !ok { + return nil, "", errPipelineRecordInvalid + } + if _, err := workflowCanonicalJSONV1(output); err != nil { + return nil, "", err + } + return output, "", nil + case "error": + code, _ := envelope["code"].(string) + if code != "pipeline_failed" && code != "result_missing" && code != "result_invalid" { + return nil, "", errPipelineRecordInvalid + } + return nil, code, nil + default: + return nil, "", errPipelineRecordInvalid + } +} + +func pipelineRecordErrorPayload(code string) map[string]any { + message := "Pipeline execution failed." + switch code { + case "result_missing": + message = "The selected result is missing." + case "result_invalid": + message = "The pipeline result is invalid." + default: + code = "pipeline_failed" + } + return map[string]any{"code": code, "message": message, "retryable": false} +} + +func pipelineRecordEarlyDispatch(args []string) (bool, int) { + if len(args) == 1 && args[0] == pipelineRecordChildCommand { + return true, runPipelineRecordChild() + } + if len(args) < 2 || args[0] != "pipeline" || args[1] != "run" { + return false, 0 + } + for index, arg := range args[2:] { + if arg == "--output=record" || arg == "-output=record" || + ((arg == "--output" || arg == "-output") && index+3 < len(args) && args[index+3] == "record") { + if err := runPipelineRun(args[2:]); err != nil { + return true, 1 + } + return true, 0 + } + } + return false, 0 +} + +func workflowCanonicalJSONV1(value any) ([]byte, error) { + var buf bytes.Buffer + write := func(data []byte) error { + if len(data) > maxPipelineRecordBytes-buf.Len() { + return errPipelineRecordInvalid + } + _, _ = buf.Write(data) + return nil + } + quoted := func(s string) error { + if !utf8.ValidString(s) || len(s) > maxPipelineRecordBytes { + return errPipelineRecordInvalid + } + var encoded bytes.Buffer + encoder := json.NewEncoder(&encoded) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(s); err != nil { + return errPipelineRecordInvalid + } + return write(bytes.TrimSuffix(encoded.Bytes(), []byte{'\n'})) + } + var encode func(any, int) error + encode = func(value any, depth int) error { + if depth > 64 { + return errPipelineRecordInvalid + } + switch value := value.(type) { + case nil: + return write([]byte("null")) + case bool: + if value { + return write([]byte("true")) + } + return write([]byte("false")) + case string: + return quoted(value) + case []any: + if err := write([]byte{'['}); err != nil { + return err + } + for i, item := range value { + if i != 0 { + if err := write([]byte{','}); err != nil { + return err + } + } + if err := encode(item, depth+1); err != nil { + return err + } + } + return write([]byte{']'}) + case map[string]any: + if err := write([]byte{'{'}); err != nil { + return err + } + for i, key := range slices.Sorted(maps.Keys(value)) { + if i != 0 { + if err := write([]byte{','}); err != nil { + return err + } + } + if err := quoted(key); err != nil { + return err + } + if err := write([]byte{':'}); err != nil { + return err + } + if err := encode(value[key], depth+1); err != nil { + return err + } + } + return write([]byte{'}'}) + default: + return errPipelineRecordInvalid + } + } + if err := encode(value, 0); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func encodePipelineResultFrame(value map[string]any) ([]byte, error) { + payload, err := workflowCanonicalJSONV1(value) + if err != nil { + return nil, err + } + frame := make([]byte, 8, 8+len(payload)) + copy(frame, "WFR1") + binary.BigEndian.PutUint32(frame[4:8], uint32(len(payload))) // #nosec G115 -- canonical encoder bounds payload to 1 MiB. + return append(frame, payload...), nil +} + +func decodePipelineResultFrame(frame []byte) (map[string]any, error) { + if len(frame) < 8 || string(frame[:4]) != "WFR1" { + return nil, errPipelineRecordInvalid + } + length := binary.BigEndian.Uint32(frame[4:8]) + if length == 0 || length > maxPipelineRecordBytes || int(length) != len(frame)-8 { + return nil, errPipelineRecordInvalid + } + decoder := json.NewDecoder(bytes.NewReader(frame[8:])) + decoder.UseNumber() + var value map[string]any + if err := decoder.Decode(&value); err != nil || value == nil { + return nil, errPipelineRecordInvalid + } + canonical, err := workflowCanonicalJSONV1(value) + if err != nil || !bytes.Equal(canonical, frame[8:]) { + return nil, errPipelineRecordInvalid + } + return value, nil +} + +func validatePipelineRecordSelectors(result, success, failure string) error { + validPrefix := func(s string) bool { + if len(s) == 0 || len(s) > 64 || s[0] < 'A' || s[0] > 'Z' { + return false + } + for _, c := range s { + if c != '_' && (c < 'A' || c > 'Z') && (c < '0' || c > '9') { + return false + } + } + return true + } + if !validPrefix(success) || !validPrefix(failure) || success == failure { + return fmt.Errorf("record prefixes must be distinct ASCII [A-Z][A-Z0-9_]{0,63}") + } + if result == "" || !utf8.ValidString(result) || strings.ContainsAny(result, "{}") || strings.ContainsFunc(result, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) { + return fmt.Errorf("record result-step must be one literal reachable step name") + } + return nil +} + +func writePipelineRecord(w io.Writer, prefix string, value map[string]any) error { + payload, err := workflowCanonicalJSONV1(value) + if err != nil { + return err + } + line := append([]byte(prefix+" "), payload...) + line = append(line, '\n') + n, err := w.Write(line) + if err != nil { + return err + } + if n != len(line) { + return io.ErrShortWrite + } + return nil +} + +func stoppedPipelineError(result *module.StepResult) error { + if result == nil || !result.Stop { + return nil + } + for _, key := range []string{"error", "_error"} { + if value, exists := result.Output[key]; exists && value != nil && value != "" { + return errors.New("pipeline step reported a stopped error") + } + } + return nil +} diff --git a/cmd/wfctl/pipeline_record_child_unix.go b/cmd/wfctl/pipeline_record_child_unix.go new file mode 100644 index 00000000..0226e874 --- /dev/null +++ b/cmd/wfctl/pipeline_record_child_unix.go @@ -0,0 +1,141 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/GoCodeAlone/workflow/sandbox" + "golang.org/x/sys/unix" +) + +func pipelineRecordChildEnvironment(source []string, toolDirectory string) []string { + env := make([]string, 0, len(source)+3) + for _, variable := range source { + name, _, _ := strings.Cut(variable, "=") + if strings.HasPrefix(name, "DOCKER_") || name == "PATH" { + continue + } + env = append(env, variable) + } + return append(env, "PATH="+toolDirectory, "DOCKER_CONFIG="+toolDirectory, + "DOCKER_HOST=unix:///wfctl-record-mode-daemon-denied.sock") +} + +func runPipelineRecordChild() int { + if syscall.Getpgrp() != os.Getpid() { + return 1 + } + // Parent teardown closes captured stdout/stderr. Forwarding must return + // EPIPE instead of terminating the leader before its custody defer runs. + signal.Ignore(syscall.SIGPIPE) + // Do this before config, engine, plugin, or sandbox construction. A native + // plugin's exec must never inherit either private pipe or broker endpoint. + for _, fd := range []int{3, 4, 5, 6} { + if _, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0); err != nil { // #nosec G115 -- fd is one of the fixed descriptors 3..6. + return 1 + } + unix.CloseOnExec(fd) + } + input := os.NewFile(3, "pipeline private input") + result := os.NewFile(4, "pipeline private result") + control := os.NewFile(5, "pipeline private cancellation") + brokerFile := os.NewFile(6, "pipeline private sandbox broker") + defer input.Close() + defer result.Close() + defer control.Close() + brokerConn, err := net.FileConn(brokerFile) + _ = brokerFile.Close() + if err != nil { + return 1 + } + defer brokerConn.Close() + signalCtx, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() + ctx, cancel := context.WithCancel(signalCtx) + defer cancel() + go func() { _, _ = io.Copy(io.Discard, control); cancel() }() + broker := newPipelineBrokerClient(brokerConn) + ctx = sandbox.WithLocalRunnerFactory(ctx, broker.Runner) + data, err := io.ReadAll(io.LimitReader(input, maxPipelineRecordBytes+1)) + _ = input.Close() + if err != nil || len(data) > maxPipelineRecordBytes || validatePipelinePrivateJSON(data) != nil { + return 1 + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var request pipelineRecordRequest + if decoder.Decode(&request) != nil || decoder.Decode(new(any)) != io.EOF { + return 1 + } + if request.ParentPID <= 0 || request.ParentStart == "" { + return 1 + } + defer retainPipelineRecordChildCustody() + if request.ParentPID != os.Getppid() { + return 1 + } + parentStart, err := pipelineProcessStartToken(request.ParentPID) + if err != nil || parentStart != request.ParentStart { + return 1 + } + if ctx.Err() != nil { + return 1 + } + envelope := executePipelineRecord(ctx, request) + frame, err := encodePipelineResultFrame(envelope) + if err != nil { + frame, err = encodePipelineResultFrame(map[string]any{"status": "error", "code": "result_invalid"}) + } + if err != nil { + return 1 + } + if count, err := result.Write(frame); err != nil || count != len(frame) { + return 1 + } + if err := result.Close(); err != nil { + return 1 + } + // Keep the group leader alive until the parent has drained the result and + // requested shutdown; no child may race a late Docker request past cleanup. + <-ctx.Done() + return 0 +} + +func retainPipelineRecordChildCustody() { + // Parent teardown can race any liveness lookup or pipe return. Keep our + // leader pinned until other actors are gone; census errors are not empty. + pid := os.Getpid() + for _, signal := range []unix.Signal{0, unix.SIGTERM} { + deadline := time.Now().Add(5 * time.Second) + for syscall.Getpgrp() == pid { + if active, err := pipelineProcessGroupActiveExcept(pid, pid); err == nil && !active { + return + } + if signal != 0 { + _ = unix.Kill(-pid, signal) + signal = 0 + } + if time.Now().After(deadline) { + break + } + time.Sleep(25 * time.Millisecond) + } + } + for syscall.Getpgrp() == pid { + if active, err := pipelineProcessGroupActiveExcept(pid, pid); err == nil && !active { + return + } + _ = unix.Kill(-pid, unix.SIGKILL) + time.Sleep(25 * time.Millisecond) + } +} diff --git a/cmd/wfctl/pipeline_record_child_unix_test.go b/cmd/wfctl/pipeline_record_child_unix_test.go new file mode 100644 index 00000000..c6f2b1ec --- /dev/null +++ b/cmd/wfctl/pipeline_record_child_unix_test.go @@ -0,0 +1,430 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "slices" + "strings" + "syscall" + "testing" + "time" + + "github.com/GoCodeAlone/workflow/config" + "golang.org/x/sys/unix" +) + +const pipelineCustodyFixtureMarker = "--wfctl-child-custody-fixture" + +func pipelineCustodyFixtureCommand(t *testing.T, role, root, mode string) *exec.Cmd { + t.Helper() + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.Command(binary, "-test.run=^TestPipelineRecordChildCustodyHelper$", "--", pipelineCustodyFixtureMarker, role, root, mode) + // Synthetic helpers must not linger in the race runtime's exit sleep + // after custody has returned. Keep instrumentation and reporting enabled. + cmd.Env = append(os.Environ(), "GORACE="+os.Getenv("GORACE")+" atexit_sleep_ms=0") + return cmd +} + +func TestPipelineRecordChildCustodyFixtureRaceOptions(t *testing.T) { + const options = "exitcode=77 halt_on_error=1 atexit_sleep_ms=1000" + t.Setenv("GORACE", options) + cmd := pipelineCustodyFixtureCommand(t, "child", t.TempDir(), "empty") + for _, variable := range cmd.Environ() { + if value, ok := strings.CutPrefix(variable, "GORACE="); ok { + if value != options+" atexit_sleep_ms=0" { + t.Fatalf("fixture changed race reporting options: %q", value) + } + return + } + } + t.Fatal("fixture did not normalize its race-runtime exit sleep") +} + +func writePipelineCustodyIdentity(t *testing.T, path string, pid int) pipelineProcessIdentity { + t.Helper() + start, err := pipelineProcessStartToken(pid) + if err != nil { + t.Fatal(err) + } + pgid, err := unix.Getpgid(pid) + if err != nil { + t.Fatal(err) + } + identity := pipelineProcessIdentity{PID: pid, PGID: pgid, Start: start} + data, err := json.Marshal(identity) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + return identity +} + +func waitPipelineCustodyFile(t *testing.T, path string) []byte { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(path); err == nil { + return data + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("custody fixture did not reach %s", path) + return nil +} + +func pipelineCustodyExitTrace(root string) string { + path := filepath.Join(root, "exit") + data, readErr := os.ReadFile(path) + info, statErr := os.Stat(path) + if readErr != nil || statErr != nil { + return fmt.Sprintf("helper_exit=%q read=%v stat=%v", data, readErr, statErr) + } + return fmt.Sprintf("helper_exit=%s exit_marker_age=%s", data, time.Since(info.ModTime())) +} + +func TestPipelineRecordChildCustodyHelper(t *testing.T) { + index := slices.Index(os.Args, pipelineCustodyFixtureMarker) + if index < 0 { + return + } + role, root, mode := os.Args[index+1], os.Args[index+2], os.Args[index+3] + switch role { + case "actor": + if mode == "resistant" { + signal.Ignore(syscall.SIGTERM) + } + writePipelineCustodyIdentity(t, filepath.Join(root, "actor.json"), os.Getpid()) + case "child": + for _, fd := range []int{3, 4, 5, 6} { + unix.CloseOnExec(fd) + } + if mode != "empty" { + actor := pipelineCustodyFixtureCommand(t, "actor", root, mode) + if err := actor.Start(); err != nil { + t.Fatal(err) + } + go func() { _ = actor.Wait() }() + waitPipelineCustodyFile(t, filepath.Join(root, "actor.json")) + } + if mode == "stdout" || mode == "stderr" { + go func() { + waitPipelineCustodyFile(t, filepath.Join(root, "emit")) + writer := os.Stdout + if mode == "stderr" { + writer = os.Stderr + } + // Match go-plugin's grpcStdioClient.Run forwarding operation. + _, err := io.Copy(writer, bytes.NewReader([]byte("late SDK output\n"))) + outcome := "not EPIPE" + if errors.Is(err, syscall.EPIPE) { + outcome = "EPIPE" + } + _ = os.WriteFile(filepath.Join(root, "output-error"), []byte(outcome), 0600) + }() + } + code := runPipelineRecordChild() + _ = os.WriteFile(filepath.Join(root, "exit"), []byte(fmt.Sprint(code)), 0600) + os.Exit(code) + case "parent": + inputRead, inputWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + resultRead, resultWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + controlRead, controlWrite, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + sockets, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + t.Fatal(err) + } + for _, fd := range sockets { + unix.CloseOnExec(fd) + } + brokerParent := os.NewFile(uintptr(sockets[0]), "fixture parent broker") + brokerChild := os.NewFile(uintptr(sockets[1]), "fixture child broker") + defer brokerParent.Close() + child := pipelineCustodyFixtureCommand(t, "child", root, mode) + log, err := os.Create(filepath.Join(root, "child.log")) + if err != nil { + t.Fatal(err) + } + defer log.Close() + child.Stderr = log + child.ExtraFiles = []*os.File{inputRead, resultWrite, controlRead, brokerChild} + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := child.Start(); err != nil { + t.Fatal(err) + } + for _, file := range []*os.File{inputRead, resultWrite, controlRead, brokerChild} { + _ = file.Close() + } + defer controlWrite.Close() + writePipelineCustodyIdentity(t, filepath.Join(root, "child.json"), child.Process.Pid) + parentStart, err := pipelineProcessStartToken(os.Getpid()) + if err != nil { + t.Fatal(err) + } + request := pipelineRecordRequest{ParentPID: os.Getpid(), ParentStart: parentStart} + if mode == "epipe" { + request.Config, err = config.LoadFromBytes([]byte(` +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.set + config: + values: {payload: "{{ .payload }}"} +`)) + if err != nil { + t.Fatal(err) + } + request.Pipeline, request.ResultStep = "selected", "result" + request.Input = map[string]any{"payload": strings.Repeat("x", 1<<18)} + } + data, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + if _, err := inputWrite.Write(data); err != nil { + t.Fatal(err) + } + _ = inputWrite.Close() + // A large real engine result stays blocked in Write until parent death + // closes the only reader; the small control reaches ctx.Done instead. + if mode == "epipe" { + var first [1]byte + if _, err := io.ReadFull(resultRead, first[:]); err != nil { + t.Fatal(err) + } + } else { + var header [8]byte + if _, err := io.ReadFull(resultRead, header[:]); err != nil { + t.Fatal(err) + } + if _, err := io.CopyN(io.Discard, resultRead, int64(binary.BigEndian.Uint32(header[4:]))); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "ready"), nil, 0600); err != nil { + t.Fatal(err) + } + } + for { + time.Sleep(time.Minute) + } +} + +func TestPipelineRecordChildClosedCaptureKeepsCustody(t *testing.T) { + for _, mode := range []string{"stdout", "stderr"} { + t.Run(mode, func(t *testing.T) { + t.Logf("toolchain=%s capture=%s", runtime.Version(), mode) + root := t.TempDir() + pipe := func() (*os.File, *os.File) { + t.Helper() + read, write, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = read.Close(); _ = write.Close() }) + return read, write + } + inputRead, inputWrite := pipe() + resultRead, resultWrite := pipe() + controlRead, controlWrite := pipe() + captureRead, captureWrite := pipe() + sockets, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + t.Fatal(err) + } + for _, fd := range sockets { + unix.CloseOnExec(fd) + } + brokerParent := os.NewFile(uintptr(sockets[0]), "capture fixture parent broker") + brokerChild := os.NewFile(uintptr(sockets[1]), "capture fixture child broker") + t.Cleanup(func() { _ = brokerParent.Close(); _ = brokerChild.Close() }) + child := pipelineCustodyFixtureCommand(t, "child", root, mode) + if mode == "stdout" { + child.Stdout = captureWrite + } else { + child.Stderr = captureWrite + } + child.ExtraFiles = []*os.File{inputRead, resultWrite, controlRead, brokerChild} + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := child.Start(); err != nil { + t.Fatal(err) + } + reaped := make(chan error, 1) + go func() { reaped <- child.Wait() }() + childReaped := false + t.Cleanup(func() { + for _, name := range []string{"actor.json", "child.json"} { + data, err := os.ReadFile(filepath.Join(root, name)) + var identity pipelineProcessIdentity + if err == nil && json.Unmarshal(data, &identity) == nil { + if token, err := pipelineProcessStartToken(identity.PID); err == nil && token == identity.Start { + _ = unix.Kill(identity.PID, unix.SIGKILL) + } + } + } + if !childReaped { + _ = child.Process.Kill() + <-reaped + } + }) + for _, file := range []*os.File{inputRead, resultWrite, controlRead, brokerChild, captureWrite} { + _ = file.Close() + } + identity := writePipelineCustodyIdentity(t, filepath.Join(root, "child.json"), child.Process.Pid) + parentStart, err := pipelineProcessStartToken(os.Getpid()) + if err != nil { + t.Fatal(err) + } + data, err := json.Marshal(pipelineRecordRequest{ParentPID: os.Getpid(), ParentStart: parentStart}) + if err != nil { + t.Fatal(err) + } + if _, err := inputWrite.Write(data); err != nil { + t.Fatal(err) + } + _ = inputWrite.Close() + _ = resultRead.SetReadDeadline(time.Now().Add(5 * time.Second)) + var header [8]byte + if _, err := io.ReadFull(resultRead, header[:]); err != nil { + t.Fatal(err) + } + if _, err := io.CopyN(io.Discard, resultRead, int64(binary.BigEndian.Uint32(header[4:]))); err != nil { + t.Fatal(err) + } + // Reader loss matches parent death; EOF makes the real child enter + // custody while the forwarding goroutine writes to FD1 or FD2. + _ = captureRead.Close() + _ = controlWrite.Close() + if err := os.WriteFile(filepath.Join(root, "emit"), nil, 0600); err != nil { + t.Fatal(err) + } + select { + case err := <-reaped: + childReaped = true + status := child.ProcessState.Sys().(syscall.WaitStatus) + active, censusErr := pipelineProcessGroupActiveExcept(identity.PGID, identity.PID) + t.Fatalf("capture loss bypassed custody: wait=%v signal=%v SIGPIPE=%t descendants_active=%v census_err=%v", err, status.Signal(), status.Signal() == syscall.SIGPIPE, active, censusErr) + case <-time.After(350 * time.Millisecond): + } + if outcome := string(waitPipelineCustodyFile(t, filepath.Join(root, "output-error"))); outcome != "EPIPE" { + t.Fatalf("forwarding did not return EPIPE: %q", outcome) + } + if token, err := pipelineProcessStartToken(identity.PID); err != nil || token != identity.Start { + t.Fatalf("capture loss abandoned the group leader: %q, %v", token, err) + } + if err := stopPipelineProcessGroup(identity, 500*time.Millisecond); err != nil { + t.Fatal(err) + } + select { + case err := <-reaped: + childReaped = true + if err != nil { + t.Fatalf("child failed normal supervised shutdown: %v %s", err, pipelineCustodyExitTrace(root)) + } + case <-time.After(2 * time.Second): + t.Fatal("child did not finish supervised shutdown") + } + if active, err := pipelineProcessGroupActive(identity.PGID); err != nil || active { + t.Fatalf("capture loss left an active process group: %v, %v", active, err) + } + }) + } +} + +func TestPipelineRecordChildOrphanCustody(t *testing.T) { + for _, mode := range []string{"control", "epipe", "empty", "resistant"} { + t.Run(mode, func(t *testing.T) { + root := t.TempDir() + parent := pipelineCustodyFixtureCommand(t, "parent", root, mode) + if err := parent.Start(); err != nil { + t.Fatal(err) + } + parentReaped := false + t.Cleanup(func() { + if !parentReaped { + _ = parent.Process.Kill() + _ = parent.Wait() + } + for _, name := range []string{"child.json", "actor.json"} { + data, err := os.ReadFile(filepath.Join(root, name)) + var identity pipelineProcessIdentity + if err == nil && json.Unmarshal(data, &identity) == nil { + if token, err := pipelineProcessStartToken(identity.PID); err == nil && token == identity.Start { + _ = unix.Kill(identity.PID, unix.SIGKILL) + } + } + } + }) + waitPipelineCustodyFile(t, filepath.Join(root, "ready")) + var child pipelineProcessIdentity + if err := json.Unmarshal(waitPipelineCustodyFile(t, filepath.Join(root, "child.json")), &child); err != nil { + t.Fatal(err) + } + if err := parent.Process.Kill(); err != nil { + t.Fatal(err) + } + _ = parent.Wait() + parentReaped = true + if mode != "empty" { + time.Sleep(350 * time.Millisecond) + if token, err := pipelineProcessStartToken(child.PID); err != nil || token != child.Start { + data, _ := os.ReadFile(filepath.Join(root, "actor.json")) + exit, _ := os.ReadFile(filepath.Join(root, "exit")) + log, _ := os.ReadFile(filepath.Join(root, "child.log")) + t.Logf("actor=%s child_exit=%s trace=%s", data, exit, log) + t.Fatalf("orphan child abandoned live descendants and its PID/PGID custody: %q, %v", token, err) + } + if mode != "resistant" { + if err := stopPipelineProcessGroup(child, 500*time.Millisecond); err != nil { + t.Fatalf("restart could not reconcile the pinned orphan group: %v", err) + } + } + } + deadline := time.Now().Add(time.Second) + if mode == "resistant" { + deadline = time.Now().Add(12 * time.Second) + } + for time.Now().Before(deadline) { + if active, err := pipelineProcessGroupActive(child.PGID); err == nil && !active { + if mode == "empty" { + exit, err := os.ReadFile(filepath.Join(root, "exit")) + if err != nil || string(exit) != "0" { + t.Fatalf("empty descendants did not preserve normal exit: %s, %v", exit, err) + } + } + return + } + time.Sleep(10 * time.Millisecond) + } + data, _ := os.ReadFile(filepath.Join(root, "actor.json")) + exit, _ := os.ReadFile(filepath.Join(root, "exit")) + t.Fatalf("orphan child did not empty its process group: actor=%s child_exit=%s %s", data, exit, pipelineCustodyExitTrace(root)) + }) + } +} diff --git a/cmd/wfctl/pipeline_record_execute.go b/cmd/wfctl/pipeline_record_execute.go new file mode 100644 index 00000000..36bc268e --- /dev/null +++ b/cmd/wfctl/pipeline_record_execute.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "fmt" + "io" + "log/slog" + + "github.com/GoCodeAlone/modular" + "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/handlers" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin" + pluginpipeline "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" +) + +type pipelineRecordRequest struct { + Config *config.WorkflowConfig `json:"config"` + Pipeline string `json:"pipeline"` + ResultStep string `json:"result_step"` + PluginDir string `json:"plugin_dir"` + Input map[string]any `json:"input"` + ParentPID int `json:"parent_pid"` + ParentStart string `json:"parent_start"` +} + +func executePipelineRecord(ctx context.Context, request pipelineRecordRequest) map[string]any { + failure := func(code string) map[string]any { + return map[string]any{"status": "error", "code": code} + } + if request.Config == nil { + return failure("pipeline_failed") + } + closure, err := selectPipelineClosure(request.Config, request.Pipeline, true) + if err != nil || closure.names[request.ResultStep] != 1 { + return failure("result_invalid") + } + installed, err := inspectPipelineInstallations(request.PluginDir) + if err != nil { + return failure("pipeline_failed") + } + names, err := installed.resolve(closure.types) + if err != nil { + return failure("pipeline_failed") + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + builtin := pluginpipeline.New() + engine, err := workflow.NewEngineBuilder().WithLogger(logger). + WithHandler(handlers.NewPipelineWorkflowHandler()).WithPlugin(builtin).Build() + if err != nil { + return failure("pipeline_failed") + } + registerPipelineFailureFactories(engine, builtin.StepFactories()) + shutdown, err := loadPipelineExternalPlugins(engine, request.PluginDir, names, logger) + if err != nil { + return failure("pipeline_failed") + } + defer shutdown() + if err := engine.BuildFromConfig(closure.config); err != nil { + return failure("pipeline_failed") + } + pipeline, ok := engine.GetPipeline(request.Pipeline) + if !ok { + return failure("pipeline_failed") + } + pc, err := pipeline.Execute(ctx, request.Input) + if err != nil || ctx.Err() != nil { + return failure("pipeline_failed") + } + output, exists := pc.StepOutputs[request.ResultStep] + if !exists { + return failure("result_missing") + } + if _, err := workflowCanonicalJSONV1(output); err != nil { + return failure("result_invalid") + } + return map[string]any{"status": "ok", "output": output} +} + +type pipelineFailureStep struct{ inner module.PipelineStep } + +func (s pipelineFailureStep) Name() string { return s.inner.Name() } + +func (s pipelineFailureStep) Execute(ctx context.Context, pc *module.PipelineContext) (*module.StepResult, error) { + result, err := s.inner.Execute(ctx, pc) + if err == nil { + err = stoppedPipelineError(result) + } + return result, err +} + +func registerPipelineFailureFactories(engine *workflow.StdEngine, factories map[string]plugin.StepFactory) { + for stepType, factory := range factories { + engine.AddStepType(stepType, func(name string, cfg map[string]any, app modular.Application) (module.PipelineStep, error) { + raw, err := factory(name, cfg, app) + if err != nil { + return nil, err + } + step, ok := raw.(module.PipelineStep) + if !ok || step == nil { + return nil, fmt.Errorf("record step factory returned an invalid pipeline step") + } + return pipelineFailureStep{inner: step}, nil + }) + } +} diff --git a/cmd/wfctl/pipeline_record_host_test.go b/cmd/wfctl/pipeline_record_host_test.go new file mode 100644 index 00000000..3c46f1a0 --- /dev/null +++ b/cmd/wfctl/pipeline_record_host_test.go @@ -0,0 +1,963 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "reflect" + "slices" + "strconv" + "strings" + "syscall" + "testing" + "time" +) + +func pipelineRecordHostTempDir(t *testing.T) string { + t.Helper() + path, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return path +} + +func buildPipelineRecordHostBinary(t *testing.T, output, target string) { + t.Helper() + buildPipelineRecordHostGo(t, output, target, true) +} + +func buildPipelineRecordHostGo(t *testing.T, output, target string, race bool) { + t.Helper() + if binary := os.Getenv("WFCTL_RECORD_HOST_BINARY"); target == "." && binary != "" { + if !filepath.IsAbs(binary) { + t.Fatal("prebuilt host binary must be an absolute path") + } + info, err := os.Lstat(binary) + if err != nil || !info.Mode().IsRegular() { + t.Fatalf("prebuilt host binary must be an existing regular file: %v", err) + } + copyPipelineRecordHostBinary(t, binary, output) + return + } + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + args := []string{"build", "-o", output} + if race { + args = append(args, "-race") + } + args = append(args, target) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Env = append(os.Environ(), "GOWORK=off") + if data, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build actual host/SDK fixture %s: %v\n%s", target, err, data) + } +} + +func TestPipelineRecordHostPrebuiltBinary(t *testing.T) { + if output := os.Getenv("WFCTL_RECORD_HOST_COPY_PROBE_OUTPUT"); output != "" { + buildPipelineRecordHostGo(t, output, ".", true) + return + } + root := pipelineRecordHostTempDir(t) + source := filepath.Join(root, "preverified-download") + want := []byte("helper-unit-sentinel: not a runtime executable\n") + if err := os.WriteFile(source, want, 0600); err != nil { + t.Fatal(err) + } + symlink := filepath.Join(root, "download-link") + if err := os.Symlink(source, symlink); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + source string + valid bool + }{ + {name: "selected-bytes", source: source, valid: true}, + {name: "missing-no-source-fallback", source: filepath.Join(root, "missing")}, + {name: "relative-no-source-fallback", source: "relative-download"}, + {name: "directory-no-source-fallback", source: root}, + {name: "symlink-no-source-fallback", source: symlink}, + } { + t.Run(tc.name, func(t *testing.T) { + output := filepath.Join(pipelineRecordHostTempDir(t), "wfctl") + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, self, "-test.run=^TestPipelineRecordHostPrebuiltBinary$", "-test.count=1") + cmd.Env = append(os.Environ(), "WFCTL_RECORD_HOST_COPY_PROBE_OUTPUT="+output, + "WFCTL_RECORD_HOST_BINARY="+tc.source, "PATH="+filepath.Join(root, "no-build-tools")) + data, runErr := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("prebuilt selection probe exceeded deadline: %v\n%s", ctx.Err(), data) + } + if !tc.valid { + if runErr == nil || !bytes.Contains(data, []byte("prebuilt host binary")) { + t.Fatalf("invalid explicit binary did not fail closed before source build: error=%v\n%s", runErr, data) + } + if _, err := os.Stat(output); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("invalid explicit binary created a host output: %v", err) + } + return + } + if runErr != nil { + t.Fatalf("explicit binary was not selected without build tools: %v\n%s", runErr, data) + } + got, err := os.ReadFile(output) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("selected host bytes differ from the explicit source: %q, %v", got, err) + } + info, err := os.Stat(output) + if err != nil || info.Mode().Perm() != 0700 { + t.Fatalf("copied host must have mode 0700: %v, %v", info, err) + } + }) + } +} + +func runPipelineRecordHost(t *testing.T, binary, config, pluginDir string, extra ...string) ([]byte, []byte, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, pipelineRecordHostArgs(config, pluginDir, extra...)...) + cmd.Cancel = func() error { return cmd.Process.Signal(syscall.SIGTERM) } + cmd.WaitDelay = 15 * time.Second + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if ctx.Err() != nil { + t.Fatalf("built wfctl did not finish within its deadline: %v\nstdout=%s\nstderr=%s", ctx.Err(), stdout.Bytes(), stderr.Bytes()) + } + return stdout.Bytes(), stderr.Bytes(), err +} + +func pipelineRecordHostArgs(config, pluginDir string, extra ...string) []string { + args := []string{"pipeline", "run", "-c", config, "-p", "selected", "--plugin-dir", pluginDir, + "--output", "record", "--result-step", "result", "--record-prefix", "SDK_RECORD_V1", "--error-prefix", "SDK_RECORD_ERROR_V1"} + return append(args, extra...) +} + +func configurePipelineRecordHostDocker(t *testing.T, root string) { + t.Helper() + dir := filepath.Join(root, "docker-bin") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + buildPipelineRecordHostGo(t, filepath.Join(dir, "docker"), "./testdata/pipeline-record/docker", false) + for _, key := range []string{"DOCKER_CONTEXT", "DOCKER_HOST", "DOCKER_TLS", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "DOCKER_CONFIG"} { + t.Setenv(key, "") + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", root) + t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state")) +} + +func TestPipelineRecordHostBuiltin(t *testing.T) { + root := pipelineRecordHostTempDir(t) + binary := filepath.Join(root, "wfctl") + buildPipelineRecordHostBinary(t, binary, ".") + configurePipelineRecordHostDocker(t, root) + stdout, stderr, err := runPipelineRecordHost(t, binary, "testdata/pipeline-record/builtin.yaml", "") + if err != nil || string(stdout) != "SDK_RECORD_V1 {\"ready\":true,\"source\":\"builtin\"}\n" { + t.Fatalf("built wfctl did not emit one canonical builtin success record: error=%v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + assertPipelineRecordHostJournalEmpty(t, root) + t.Run("cli-print-quarantine", func(t *testing.T) { + caseRoot := pipelineRecordHostTempDir(t) + t.Setenv("XDG_STATE_HOME", filepath.Join(caseRoot, "state")) + stdout, stderr, runErr := runPipelineRecordHost(t, binary, "testdata/pipeline-record/cli-print.yaml", "") + assertPipelineRecordHostError(t, stdout, stderr, runErr, "quarantine") + assertPipelineRecordHostJournalEmpty(t, caseRoot) + }) +} + +func TestPipelineRecordHostSDK(t *testing.T) { + root := pipelineRecordHostTempDir(t) + binary := filepath.Join(root, "wfctl") + buildPipelineRecordHostBinary(t, binary, ".") + pluginDir := filepath.Join(root, "plugins") + installed := filepath.Join(pluginDir, "record-fixture") + if err := os.MkdirAll(installed, 0700); err != nil { + t.Fatal(err) + } + buildPipelineRecordHostBinary(t, filepath.Join(installed, "record-fixture"), "./testdata/pipeline-record/plugin") + manifest, err := os.ReadFile("testdata/pipeline-record/plugin/plugin.json") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "plugin.json"), manifest, 0600); err != nil { + t.Fatal(err) + } + configurePipelineRecordHostDocker(t, root) + for _, tc := range []struct { + name string + mode string + config string + code string + branch bool + late bool + composite bool + }{ + {name: "success", mode: "success"}, + {name: "descriptors", mode: "descriptors"}, + {name: "error", mode: "error", code: "pipeline_failed"}, + {name: "stopped-error", mode: "stopped-error", code: "pipeline_failed"}, + {name: "number", mode: "number", code: "result_invalid"}, + {name: "result-overflow", mode: "result-overflow", code: "result_invalid"}, + {name: "invalid-utf8", mode: "invalid-utf8", code: "pipeline_failed"}, + {name: "stdout", mode: "stdout", code: "quarantine"}, + {name: "stdout-overflow", mode: "stdout-overflow", code: "quarantine"}, + {name: "stderr", mode: "stderr"}, + {name: "stderr-overflow", mode: "stderr-overflow", code: "quarantine"}, + {name: "late-stdout", mode: "late-stdout", config: "late-stdout.yaml", code: "quarantine", late: true}, + {name: "late-stdout-overflow", mode: "late-stdout-overflow", config: "late-stdout.yaml", code: "quarantine", late: true}, + {name: "branch-success", mode: "success", config: "branch.yaml", branch: true}, + {name: "branch-stopped-error", mode: "stopped-error", config: "branch.yaml", code: "pipeline_failed"}, + {name: "workflow-call-success", mode: "success", config: "workflow-call.yaml"}, + {name: "workflow-call-stopped-error", mode: "stopped-error", config: "workflow-call.yaml", code: "pipeline_failed"}, + {name: "missing-result", mode: "stop", config: "missing-result.yaml", code: "result_missing"}, + {name: "foreach", config: "foreach.yaml", composite: true}, + {name: "parallel", config: "parallel.yaml", composite: true}, + {name: "while", config: "while.yaml", composite: true}, + {name: "retry-with-backoff", config: "retry-with-backoff.yaml", composite: true}, + {name: "resilient-circuit-breaker", config: "resilient-circuit-breaker.yaml", composite: true}, + {name: "compensation", config: "compensation.yaml", code: "pipeline_failed", composite: true}, + } { + t.Run(tc.name, func(t *testing.T) { + if tc.composite { + runPipelineRecordHostComposite(t, binary, pluginDir, tc.name, tc.config, tc.code) + return + } + caseRoot := pipelineRecordHostTempDir(t) + t.Setenv("XDG_STATE_HOME", filepath.Join(caseRoot, "state")) + auditPath := filepath.Join(caseRoot, "sdk-execution.jsonl") + config := tc.config + if config == "" { + config = "external.yaml" + } + stdout, stderr, runErr := runPipelineRecordHost(t, binary, filepath.Join("testdata/pipeline-record", config), pluginDir, + "--var", "mode="+tc.mode, "--var", "audit_path="+auditPath, "--input", `{"marker":"fixture-input-canary"}`) + audit := readPipelineRecordHostAudit(t, auditPath, tc.mode, stdout, stderr) + if tc.late { + armed := readPipelineRecordHostAudit(t, auditPath+".armed", "arm-late-stdout", stdout, stderr) + response := readPipelineRecordHostAudit(t, auditPath+".response", tc.mode+"-response-confirmed", stdout, stderr) + emitted := readPipelineRecordHostAudit(t, auditPath+".emitted", tc.mode+"-emitted", stdout, stderr) + if armed.PID != audit.PID || response.PID != audit.PID || emitted.PID != audit.PID { + t.Fatal("late-output barrier/emission did not come from the same SDK process") + } + wantEmission := map[string]string{"bytes": "64", "requested_bytes": strconv.Itoa(maxPipelineRecordBytes + 1), "stage": "prefix"} + if tc.mode == "late-stdout" { + count := strconv.Itoa(len("SDK_RECORD_V1 {\"forged_late\":true}\nfixture-late-stdout-canary\n")) + wantEmission = map[string]string{"bytes": count, "requested_bytes": count, "stage": "complete"} + } + if !reflect.DeepEqual(emitted.Data, wantEmission) { + t.Fatalf("late output did not audit actual completed bytes after the prior RPC response: got %+v, want %+v", emitted, wantEmission) + } + } + if tc.code == "" { + want := map[string]any{ + "source": "real-sdk", "echo": "fixture-input-canary", "prior": "host-prepared", "config": "host-prepared", + "process_id": audit.PID, "private_descriptors_closed": true, "detail": map[string]any{"z": "last", "a": "first"}, + } + if tc.branch { + want = map[string]any{"branch": "success", "matched_value": "success"} + } + payload, err := json.Marshal(want) + if err != nil { + t.Fatal(err) + } + if runErr != nil || string(stdout) != "SDK_RECORD_V1 "+string(payload)+"\n" { + t.Fatalf("SDK result was not selected/canonical/isolated: error=%v\nstdout=%s\nstderr=%s", runErr, stdout, stderr) + } + } else { + assertPipelineRecordHostError(t, stdout, stderr, runErr, tc.code) + } + assertPipelineRecordHostJournalEmpty(t, caseRoot) + }) + } + t.Run("runtime-ownership", func(t *testing.T) { + for _, tc := range []struct { + name string + installed string + stepTypes []string + }{ + {"extra-factory", "record-fixture", []string{"step.record_fixture"}}, + {"missing-factory", "record-fixture", []string{"step.record_fixture", "step.record_fixture_aux", "step.record_fixture_absent"}}, + {"different-factory-same-count", "record-fixture", []string{"step.record_fixture", "step.record_fixture_absent"}}, + {"runtime-name", "record-fixture-renamed", []string{"step.record_fixture", "step.record_fixture_aux"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caseRoot := pipelineRecordHostTempDir(t) + t.Setenv("XDG_STATE_HOME", filepath.Join(caseRoot, "state")) + casePlugins := filepath.Join(caseRoot, "plugins") + dir := filepath.Join(casePlugins, tc.installed) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + copyPipelineRecordHostBinary(t, filepath.Join(installed, "record-fixture"), filepath.Join(dir, tc.installed)) + var disk map[string]any + if err := json.Unmarshal(manifest, &disk); err != nil { + t.Fatal(err) + } + disk["name"], disk["stepTypes"] = tc.installed, tc.stepTypes + data, err := json.Marshal(disk) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0600); err != nil { + t.Fatal(err) + } + startupPath := filepath.Join(caseRoot, "sdk-startup.jsonl") + executePath := filepath.Join(caseRoot, "sdk-execution.jsonl") + t.Setenv("WFCTL_RECORD_FIXTURE_STARTUP", startupPath) + stdout, stderr, runErr := runPipelineRecordHost(t, binary, "testdata/pipeline-record/external.yaml", casePlugins, + "--var", "mode=success", "--var", "audit_path="+executePath) + readPipelineRecordHostAudit(t, startupPath, "startup", stdout, stderr, tc.installed) + if _, err := os.Stat(executePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("runtime identity/catalog denial allowed step execution: %v", err) + } + assertPipelineRecordHostError(t, stdout, stderr, runErr, "pipeline_failed") + assertPipelineRecordHostJournalEmpty(t, caseRoot) + }) + } + }) +} + +func runPipelineRecordHostComposite(t *testing.T, binary, pluginDir, name, config, code string) { + t.Helper() + for _, size := range []int{2, 3} { + t.Run("pool-"+strconv.Itoa(size), func(t *testing.T) { + root := pipelineRecordHostTempDir(t) + t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state")) + auditPath := filepath.Join(root, "sdk-execution.jsonl") + poolName := name + "-pool-" + strconv.Itoa(size) + pool := make([]string, size) + for i := range pool { + pool[i] = poolName + "-participant-" + strconv.Itoa(i) + } + marker := "fixture-input-canary:" + poolName + input, err := json.Marshal(map[string]any{ + "marker": marker, "pool_name": poolName, "pool": pool, "last_ordinal": strconv.Itoa(size - 1), + }) + if err != nil { + t.Fatal(err) + } + stdout, stderr, runErr := runPipelineRecordHost(t, binary, filepath.Join("testdata/pipeline-record", config), pluginDir, + "--var", "audit_path="+auditPath, "--input", string(input)) + audits := readPipelineRecordHostAudits(t, auditPath, stdout, stderr) + wantAudits, wantOutput := pipelineRecordHostCompositeExpectations(t, name, poolName, marker, pool) + if name == "foreach" || name == "parallel" { + slices.SortFunc(audits, func(a, b pipelineRecordHostAudit) int { + return strings.Compare(a.Data["ordinal"], b.Data["ordinal"]) + }) + } + if len(audits) != len(wantAudits) { + t.Fatalf("composite did not execute exactly its SDK calls: got %+v, want %+v\nstdout=%s\nstderr=%s", audits, wantAudits, stdout, stderr) + } + for i, audit := range audits { + if audit.Mode != wantAudits[i].Mode || !reflect.DeepEqual(audit.Data, wantAudits[i].Data) { + t.Fatalf("SDK call %d did not receive parent input, resolved config, or correct composite order: got %+v, want %+v", i, audit, wantAudits[i]) + } + } + if code != "" { + assertPipelineRecordHostError(t, stdout, stderr, runErr, code) + } else { + payload, err := json.Marshal(wantOutput) + if err != nil { + t.Fatal(err) + } + if runErr != nil || string(stdout) != "SDK_RECORD_V1 "+string(payload)+"\n" { + t.Fatalf("top-level result did not select nested SDK output and composite properties: error=%v\nstdout=%s\nstderr=%s\nwant=%s", runErr, stdout, stderr, payload) + } + } + assertPipelineRecordHostJournalEmpty(t, root) + }) + } +} + +func pipelineRecordHostCompositeExpectations(t *testing.T, name, poolName, marker string, pool []string) ([]pipelineRecordHostAudit, map[string]any) { + t.Helper() + var audits []pipelineRecordHostAudit + call := func(mode, action string, ordinal int, attempt string) { + audits = append(audits, pipelineRecordHostAudit{Mode: mode, Data: map[string]string{ + "participant": pool[ordinal], "pool": poolName, "ordinal": strconv.Itoa(ordinal), "action": action, + "attempt": attempt, "marker": marker, "prior_greeting": poolName, "pipeline": "selected", + }}) + } + output := map[string]any{"source": name, "pool": poolName, "echo": marker, "ready": "true"} + switch name { + case "foreach": + for i := range pool { + call("composite", "visit", i, "1") + } + output["count"], output["first"], output["last"] = strconv.Itoa(len(pool)), pool[0], pool[len(pool)-1] + case "parallel": + call("composite", "left", 0, "1") + call("composite", "right", len(pool)-1, "1") + output["completed"], output["failed"], output["left"], output["right"] = "2", "0", pool[0], pool[len(pool)-1] + case "while": + for i := range pool { + call("composite-while", "page", i, "1") + } + output["iterations"], output["last"], output["continuing"] = strconv.Itoa(len(pool)), pool[len(pool)-1], "false" + case "retry-with-backoff": + call("composite-flaky", "retry", 0, "1") + call("composite-flaky", "retry", 0, "2") + output["retries"], output["attempt"], output["participant"] = "1", "2", pool[0] + case "resilient-circuit-breaker": + call("composite-error", "primary", 1, "1") + call("composite", "fallback", 1, "1") + output["retries"], output["open"], output["action"], output["participant"] = "1", "true", "fallback", pool[1] + case "compensation": + call("composite", "forward", 0, "1") + call("composite-error", "fail", len(pool)-1, "1") + call("composite-compensate", "undo-last", len(pool)-1, "1") + call("composite-compensate", "undo-first", 0, "1") + output = nil + default: + t.Fatalf("unknown composite fixture %q", name) + } + return audits, output +} + +func copyPipelineRecordHostBinary(t *testing.T, source, destination string) { + t.Helper() + input, err := os.Open(source) + if err != nil { + t.Fatal(err) + } + defer input.Close() + output, err := os.OpenFile(destination, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0700) + if err != nil { + t.Fatal(err) + } + _, copyErr := io.Copy(output, input) + if err := errors.Join(copyErr, output.Chmod(0700), output.Close()); err != nil { + t.Fatal(err) + } +} + +type pipelineRecordHostAudit struct { + PID string `json:"pid"` + Mode string `json:"mode"` + Argv []string `json:"argv"` + Data map[string]string `json:"data,omitzero"` +} + +func readPipelineRecordHostAudit(t *testing.T, path, mode string, stdout, stderr []byte, binaryNames ...string) pipelineRecordHostAudit { + t.Helper() + audits := readPipelineRecordHostAudits(t, path, stdout, stderr, binaryNames...) + if len(audits) != 1 || audits[0].Mode != mode { + t.Fatalf("expected exactly one matching SDK execution audit: %+v", audits) + } + return audits[0] +} + +func readPipelineRecordHostAudits(t *testing.T, path string, stdout, stderr []byte, binaryNames ...string) []pipelineRecordHostAudit { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("SDK step was never invoked (preflight failures must not count as fixture denials): %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var audits []pipelineRecordHostAudit + for { + var audit pipelineRecordHostAudit + if err := decoder.Decode(&audit); err != nil { + if err == io.EOF { + break + } + t.Fatalf("invalid SDK execution audit: %v", err) + } + audits = append(audits, audit) + } + if len(audits) == 0 { + t.Fatal("empty SDK execution audit cannot prove runtime execution") + } + binaryName := "record-fixture" + if len(binaryNames) != 0 { + binaryName = binaryNames[0] + } + for _, audit := range audits { + pid, err := strconv.Atoi(audit.PID) + if err != nil || pid <= 0 || pid == os.Getpid() || len(audit.Argv) != 1 || filepath.Base(audit.Argv[0]) != binaryName || audit.PID != audits[0].PID { + t.Fatalf("SDK calls did not execute in the same real installed subprocess: %+v", audit) + } + if strings.Contains(strings.Join(audit.Argv, " "), "fixture-input-canary") { + t.Fatal("private input reached plugin argv") + } + } + pid, _ := strconv.Atoi(audits[0].PID) + // Register cleanup before asserting reaping, and only signal the same + // process identity if the host's cleanup failed. + start, startErr := pipelineProcessStartToken(pid) + if startErr == nil { + t.Cleanup(func() { + current, err := pipelineProcessStartToken(pid) + if err == nil && current == start { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + }) + } + ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second) + defer cancel() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if err := syscall.Kill(pid, 0); errors.Is(err, syscall.ESRCH) { + return audits + } + select { + case <-ctx.Done(): + t.Fatalf("SDK process %d survived built wfctl exit", pid) + case <-ticker.C: + } + } +} + +func assertPipelineRecordHostError(t *testing.T, stdout, stderr []byte, runErr error, code string) { + t.Helper() + if runErr == nil { + t.Fatalf("fixture denial was reported as success: stdout=%s stderr=%s", stdout, stderr) + } + if !bytes.HasPrefix(stdout, []byte("SDK_RECORD_ERROR_V1 ")) || bytes.Count(stdout, []byte{'\n'}) != 1 { + t.Fatalf("expected exactly one public error record: stdout=%s stderr=%s", stdout, stderr) + } + var payload map[string]any + if err := json.Unmarshal(bytes.TrimSuffix(bytes.TrimPrefix(stdout, []byte("SDK_RECORD_ERROR_V1 ")), []byte{'\n'}), &payload); err != nil { + t.Fatal(err) + } + gotCode, _ := payload["code"].(string) + messages := map[string]string{"pipeline_failed": "Pipeline execution failed.", "result_missing": "The selected result is missing.", "result_invalid": "The pipeline result is invalid."} + if messages[gotCode] == "" || (code != "quarantine" && gotCode != code) { + t.Fatalf("wrong stable public error code: %#v, want %s", payload, code) + } + want := map[string]any{"code": gotCode, "message": messages[gotCode], "retryable": false} + if !reflect.DeepEqual(payload, want) { + t.Fatalf("error was not fixed/redacted: %#v", payload) + } + canonical, err := json.Marshal(want) + if err != nil || string(stdout) != "SDK_RECORD_ERROR_V1 "+string(canonical)+"\n" { + t.Fatalf("public error was not canonical: error=%v stdout=%s", err, stdout) + } +} + +func assertPipelineRecordHostJournalEmpty(t *testing.T, root string) { + t.Helper() + entries, err := os.ReadDir(filepath.Join(root, "state", "wfctl", "pipeline-cleanup")) + if err != nil { + t.Fatalf("host never established cleanup state: %v", err) + } + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".json") { + t.Fatalf("host retained a journal after its empty fake-daemon cleanup: %s", entry.Name()) + } + } +} + +type pipelineRecordHostTransport struct { + Root string `json:"root"` + BlockCreate bool `json:"block_create"` + LateCreate bool `json:"late_create"` + Unavailable bool `json:"unavailable"` +} + +type pipelineRecordHostTransportEvent struct { + PID int `json:"pid"` + PGID int `json:"pgid"` + Label string `json:"label"` + Args []string `json:"args"` +} + +type pipelineRecordHostProcess struct { + command *exec.Cmd + done chan error + stdout *os.File + stderr *os.File + waited bool +} + +func startPipelineRecordHost(t *testing.T, root, binary, config, pluginDir string, extra ...string) *pipelineRecordHostProcess { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 45*time.Second) + process := &pipelineRecordHostProcess{done: make(chan error, 1)} + var err error + process.stdout, err = os.OpenFile(filepath.Join(root, "parent.stdout"), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + cancel() + t.Fatal(err) + } + process.stderr, err = os.OpenFile(filepath.Join(root, "parent.stderr"), os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + cancel() + _ = process.stdout.Close() + t.Fatal(err) + } + process.command = exec.CommandContext(ctx, binary, pipelineRecordHostArgs(config, pluginDir, extra...)...) + process.command.Stdout, process.command.Stderr = process.stdout, process.stderr + if err := process.command.Start(); err != nil { + cancel() + _ = process.stdout.Close() + _ = process.stderr.Close() + t.Fatal(err) + } + go func() { process.done <- process.command.Wait() }() + t.Cleanup(func() { + cancel() + if !process.waited { + _ = process.command.Process.Kill() + <-process.done + } + _ = process.stdout.Close() + _ = process.stderr.Close() + }) + return process +} + +func (p *pipelineRecordHostProcess) finish(t *testing.T) ([]byte, []byte, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 25*time.Second) + defer cancel() + var runErr error + select { + case runErr = <-p.done: + p.waited = true + case <-ctx.Done(): + t.Fatal("actual record parent did not exit within the cleanup deadline") + } + if err := errors.Join(p.stdout.Close(), p.stderr.Close()); err != nil { + t.Fatal(err) + } + stdout, err := os.ReadFile(p.stdout.Name()) + if err != nil { + t.Fatal(err) + } + stderr, err := os.ReadFile(p.stderr.Name()) + if err != nil { + t.Fatal(err) + } + return stdout, stderr, runErr +} + +func waitPipelineRecordHostCondition(t *testing.T, description string, condition func() bool) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 12*time.Second) + defer cancel() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for !condition() { + select { + case <-ctx.Done(): + t.Fatalf("actual host never reached %s (no preflight-only denial evidence)", description) + case <-ticker.C: + } + } +} + +func waitPipelineRecordHostJSON(t *testing.T, path string, target any) { + t.Helper() + waitPipelineRecordHostCondition(t, filepath.Base(path), func() bool { + data, err := os.ReadFile(path) + return err == nil && bytes.HasSuffix(data, []byte{'\n'}) && json.Unmarshal(data, target) == nil + }) +} + +func trackPipelineRecordHostProcess(t *testing.T, pid, pgid int) pipelineProcessIdentity { + t.Helper() + start, err := pipelineProcessStartToken(pid) + if err != nil { + t.Fatalf("fixture readiness process was not alive: %d: %v", pid, err) + } + group, err := syscall.Getpgid(pid) + t.Cleanup(func() { + if current, err := pipelineProcessStartToken(pid); err == nil && current == start { + if group == pid { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } else { + _ = syscall.Kill(pid, syscall.SIGKILL) + } + } + }) + if err != nil || group != pgid { + t.Fatalf("fixture process %d escaped journaled group %d: actual=%d error=%v", pid, pgid, group, err) + } + return pipelineProcessIdentity{PID: pid, PGID: pgid, Start: start} +} + +func readPipelineRecordHostEntry(t *testing.T, root string) pipelineCleanupEntry { + t.Helper() + var entry pipelineCleanupEntry + waitPipelineRecordHostCondition(t, "durable child identity journal", func() bool { + paths, err := filepath.Glob(filepath.Join(root, "state", "wfctl", "pipeline-cleanup", "*.json")) + if err != nil || len(paths) != 1 { + return false + } + data, err := os.ReadFile(paths[0]) + return err == nil && json.Unmarshal(data, &entry) == nil && entry.Child.PID > 0 + }) + if entry.Child.PGID != entry.Child.PID || entry.Child.Start == "" || entry.Parent.Start == "" { + t.Fatalf("journal did not contain full parent/child identities: %+v", entry) + } + return entry +} + +func readPipelineRecordHostTransportCalls(t *testing.T, root string) []pipelineRecordHostTransportEvent { + t.Helper() + data, err := os.ReadFile(filepath.Join(root, "calls.jsonl")) + if err != nil { + t.Fatal(err) + } + var calls []pipelineRecordHostTransportEvent + decoder := json.NewDecoder(bytes.NewReader(data)) + for { + var call pipelineRecordHostTransportEvent + if err := decoder.Decode(&call); errors.Is(err, io.EOF) { + return calls + } else if err != nil { + t.Fatal(err) + } + calls = append(calls, call) + } +} + +func TestPipelineRecordHostCleanupMatrix(t *testing.T) { + root := pipelineRecordHostTempDir(t) + binary := filepath.Join(root, "wfctl") + buildPipelineRecordHostBinary(t, binary, ".") + pluginDir := filepath.Join(root, "plugins") + installed := filepath.Join(pluginDir, "record-fixture") + if err := os.MkdirAll(installed, 0700); err != nil { + t.Fatal(err) + } + buildPipelineRecordHostBinary(t, filepath.Join(installed, "record-fixture"), "./testdata/pipeline-record/plugin") + manifest, err := os.ReadFile("testdata/pipeline-record/plugin/plugin.json") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(installed, "plugin.json"), manifest, 0600); err != nil { + t.Fatal(err) + } + configurePipelineRecordHostDocker(t, root) + transportPath := filepath.Join(root, "docker-bin", "transport.json") + for _, tc := range []struct { + name string + phase string + signal syscall.Signal + resistant bool + late bool + unavailable bool + }{ + {name: "normal-lifecycle", phase: "normal"}, + {name: "SIGKILL-before-create", phase: "before", signal: syscall.SIGKILL}, + {name: "SIGKILL-during-create-late-helper", phase: "create", signal: syscall.SIGKILL, late: true}, + {name: "SIGKILL-after-create", phase: "after", signal: syscall.SIGKILL}, + {name: "SIGTERM-resistant-SDK", phase: "before", signal: syscall.SIGTERM, resistant: true}, + {name: "SIGKILL-resistant-SDK-unavailable-retry", phase: "before", signal: syscall.SIGKILL, resistant: true, unavailable: true}, + {name: "SIGTERM-during-create-denies-late-helper", phase: "create", signal: syscall.SIGTERM, late: true}, + } { + t.Run(tc.name, func(t *testing.T) { + caseRoot := pipelineRecordHostTempDir(t) + t.Setenv("XDG_STATE_HOME", filepath.Join(caseRoot, "state")) + transport := pipelineRecordHostTransport{Root: caseRoot, BlockCreate: tc.phase == "create", LateCreate: tc.late} + writeTransport := func() { + data, err := json.Marshal(transport) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(transportPath, data, 0600); err != nil { + t.Fatal(err) + } + } + writeTransport() + beforeMode, afterMode := "success", "wait" + if tc.phase == "normal" { + afterMode = "success" + } + if tc.phase == "before" { + beforeMode = "wait" + } + if tc.resistant { + beforeMode = "term-resistant" + } + beforeAudit, afterAudit := filepath.Join(caseRoot, "before.jsonl"), filepath.Join(caseRoot, "after.jsonl") + parent := startPipelineRecordHost(t, caseRoot, binary, "testdata/pipeline-record/cleanup.yaml", pluginDir, + "--var", "before_mode="+beforeMode, "--var", "after_mode="+afterMode, + "--var", "before_audit="+beforeAudit, "--var", "after_audit="+afterAudit, + "--input", `{"marker":"fixture-input-canary"}`) + if tc.phase == "normal" { + stdout, stderr, err := parent.finish(t) + if err != nil || string(stdout) != "SDK_RECORD_V1 {\"ready\":true,\"source\":\"cleanup-fixture\"}\n" { + t.Fatalf("actual SDK/sandbox/broker lifecycle control failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + readPipelineRecordHostAudit(t, beforeAudit, "success", stdout, stderr) + readPipelineRecordHostAudit(t, afterAudit, "success", stdout, stderr) + assertPipelineRecordHostJournalEmpty(t, caseRoot) + return + } + var sdkAudit pipelineRecordHostAudit + waitPipelineRecordHostJSON(t, beforeAudit, &sdkAudit) + entry := readPipelineRecordHostEntry(t, caseRoot) + if entry.Parent.PID != parent.command.Process.Pid { + t.Fatal("journal belongs to a different CLI parent") + } + trackPipelineRecordHostProcess(t, entry.Child.PID, entry.Child.PGID) + sdkPID, err := strconv.Atoi(sdkAudit.PID) + if err != nil || sdkAudit.Mode != beforeMode { + t.Fatalf("wrong real SDK readiness marker: %+v", sdkAudit) + } + trackPipelineRecordHostProcess(t, sdkPID, entry.Child.PGID) + var lateProcess pipelineProcessIdentity + if tc.resistant { + var descendant pipelineRecordHostAudit + waitPipelineRecordHostJSON(t, beforeAudit+".descendant", &descendant) + pid, err := strconv.Atoi(descendant.PID) + if err != nil || descendant.Mode != "term-resistant-descendant" { + t.Fatalf("resistant SDK descendant was not ready: %+v", descendant) + } + trackPipelineRecordHostProcess(t, pid, entry.Child.PGID) + } + switch tc.phase { + case "create": + var creating pipelineRecordHostTransportEvent + waitPipelineRecordHostJSON(t, filepath.Join(caseRoot, "create.ready"), &creating) + if creating.Label != entry.Label { + t.Fatal("broker create used a different cleanup label") + } + trackPipelineRecordHostProcess(t, creating.PID, entry.Child.PGID) + if tc.late { + var late pipelineRecordHostTransportEvent + waitPipelineRecordHostJSON(t, filepath.Join(caseRoot, "late.ready"), &late) + lateProcess = trackPipelineRecordHostProcess(t, late.PID, entry.Child.PGID) + } + entry = readPipelineRecordHostEntry(t, caseRoot) + if len(entry.CIDFiles) != 1 || len(entry.IDs) != 0 { + t.Fatalf("create was not journaled before receiving its ID: %+v", entry) + } + case "after": + var after pipelineRecordHostAudit + waitPipelineRecordHostJSON(t, afterAudit, &after) + entry = readPipelineRecordHostEntry(t, caseRoot) + if after.Mode != "wait" || after.PID != sdkAudit.PID || len(entry.IDs) != 1 || entry.IDs[0] != strings.Repeat("a", 64) { + t.Fatalf("post-create gate did not follow successful real broker work: %+v, %+v", after, entry) + } + } + calls := readPipelineRecordHostTransportCalls(t, caseRoot) + var operations []string + for _, call := range calls { + if len(call.Args) != 0 && slices.Contains([]string{"create", "start", "wait"}, call.Args[0]) { + operations = append(operations, call.Args[0]) + if call.PGID != entry.Child.PGID { + t.Fatalf("broker CLI escaped child custody: %+v, %+v", call, entry.Child) + } + } + } + wantOperations := []string{} + switch tc.phase { + case "create": + wantOperations = []string{"create"} + case "after": + wantOperations = []string{"create", "start", "wait"} + } + if !slices.Equal(operations, wantOperations) { + t.Fatalf("wrong interruption boundary: %v, want %v", operations, wantOperations) + } + if err := parent.command.Process.Signal(tc.signal); err != nil { + t.Fatal(err) + } + stdout, stderr, runErr := parent.finish(t) + if tc.signal == syscall.SIGTERM { + assertPipelineRecordHostError(t, stdout, stderr, runErr, "pipeline_failed") + } else { + if runErr == nil || len(stdout) != 0 { + t.Fatalf("SIGKILL emitted a false public result: %v, stdout=%s stderr=%s", runErr, stdout, stderr) + } + retained := readPipelineRecordHostEntry(t, caseRoot) + if retained.Label != entry.Label || retained.Child != entry.Child || retained.Docker != entry.Docker { + t.Fatalf("SIGKILL lost durable cleanup identity: %+v", retained) + } + transport.BlockCreate, transport.LateCreate, transport.Unavailable = false, false, tc.unavailable + writeTransport() + stdout, stderr, runErr = runPipelineRecordHost(t, binary, "testdata/pipeline-record/builtin.yaml", "") + if tc.unavailable { + assertPipelineRecordHostError(t, stdout, stderr, runErr, "pipeline_failed") + retained := readPipelineRecordHostEntry(t, caseRoot) + if retained.Label != entry.Label || retained.Docker != entry.Docker { + t.Fatal("unavailable daemon lost the pinned retry journal") + } + if active, err := pipelineProcessGroupActive(entry.Child.PGID); err != nil || active { + t.Fatalf("daemon outage left the old SDK group active: %v, %v", active, err) + } + transport.Unavailable = false + writeTransport() + stdout, stderr, runErr = runPipelineRecordHost(t, binary, "testdata/pipeline-record/builtin.yaml", "") + } + if runErr != nil || string(stdout) != "SDK_RECORD_V1 {\"ready\":true,\"source\":\"builtin\"}\n" { + parentToken, parentErr := pipelineProcessStartToken(entry.Parent.PID) + childToken, childErr := pipelineProcessStartToken(entry.Child.PID) + active, groupErr := pipelineProcessGroupActive(entry.Child.PGID) + t.Logf("retained identities: parent=%+v current=%q error=%v; child=%+v current=%q error=%v; group_active=%v error=%v", + entry.Parent, parentToken, parentErr, entry.Child, childToken, childErr, active, groupErr) + t.Fatalf("restart did not reconcile before pipeline execution: %v\nstdout=%s\nstderr=%s", runErr, stdout, stderr) + } + } + if active, err := pipelineProcessGroupActive(entry.Child.PGID); err != nil || active { + t.Fatalf("record parent left its journaled process group alive: %v, %v", active, err) + } + if tc.late { + if err := os.WriteFile(filepath.Join(caseRoot, "late.release"), nil, 0600); err != nil { + t.Fatal(err) + } + waitPipelineRecordHostCondition(t, "late-helper gone or observed late create", func() bool { + _, lateErr := os.Stat(filepath.Join(caseRoot, "late.created")) + current, err := pipelineProcessStartToken(lateProcess.PID) + active, groupErr := pipelineProcessGroupActive(lateProcess.PGID) + return lateErr == nil || pipelineProcessAbsent(err) || (err == nil && current != lateProcess.Start) || (groupErr == nil && !active) + }) + if _, err := os.Stat(filepath.Join(caseRoot, "late.created")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("custodial CLI helper created after shutdown/empty cleanup: %v", err) + } + } + assertPipelineRecordHostJournalEmpty(t, caseRoot) + var containers map[string]string + state, err := os.ReadFile(filepath.Join(caseRoot, "containers.json")) + if err == nil { + if err := json.Unmarshal(state, &containers); err != nil || len(containers) != 0 { + t.Fatalf("cleanup left transport-owned container state: %s, %v", state, err) + } + } else if !errors.Is(err, os.ErrNotExist) { + t.Fatal(err) + } + entries, err := os.ReadDir(filepath.Join(caseRoot, "state", "wfctl", "pipeline-cleanup")) + if err != nil || len(entries) != 1 || entries[0].Name() != "lock" { + t.Fatalf("cleanup retained a CID file or journal: %v, %v", entries, err) + } + }) + } +} + +func TestPipelineRecordHostLiveDockerBuiltin(t *testing.T) { + if os.Getenv("WFCTL_RECORD_HOST_LIVE_DOCKER") != "1" { + t.Skip("requires explicit live Docker opt-in; fake CLI tests are not daemon-health evidence") + } + root := pipelineRecordHostTempDir(t) + binary := filepath.Join(root, "wfctl") + buildPipelineRecordHostBinary(t, binary, ".") + t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state")) + stdout, stderr, err := runPipelineRecordHost(t, binary, "testdata/pipeline-record/builtin.yaml", "") + if err != nil || string(stdout) != "SDK_RECORD_V1 {\"ready\":true,\"source\":\"builtin\"}\n" { + t.Fatalf("built wfctl failed live Docker builtin lifecycle: error=%v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + assertPipelineRecordHostJournalEmpty(t, root) +} diff --git a/cmd/wfctl/pipeline_record_live_test.go b/cmd/wfctl/pipeline_record_live_test.go new file mode 100644 index 00000000..5df83f58 --- /dev/null +++ b/cmd/wfctl/pipeline_record_live_test.go @@ -0,0 +1,102 @@ +//go:build linux || darwin + +package main + +import ( + "context" + "encoding/hex" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "gopkg.in/yaml.v3" +) + +func TestPipelineRecordHostLiveDockerSandbox(t *testing.T) { + githubLinuxCI := os.Getenv("GITHUB_ACTIONS") == "true" && os.Getenv("RUNNER_OS") == "Linux" + if os.Getenv("WFCTL_RECORD_HOST_LIVE_DOCKER") != "1" && !githubLinuxCI { + t.Skip("requires explicit live Docker opt-in; transport fixtures do not execute containers") + } + image := os.Getenv("WFCTL_RECORD_HOST_LIVE_IMAGE") + if image == "" && githubLinuxCI { + image = pullPipelineRecordLiveImage(t) + } + name, digest, ok := strings.Cut(image, "@sha256:") + if !ok || name == "" || len(digest) != 64 { + t.Fatal("live sandbox proof requires a pre-pulled digest-pinned image") + } + if _, err := hex.DecodeString(digest); err != nil { + t.Fatal("live sandbox proof image digest is invalid") + } + root := pipelineRecordHostTempDir(t) + binary := filepath.Join(root, "wfctl") + buildPipelineRecordHostBinary(t, binary, ".") + t.Setenv("XDG_STATE_HOME", filepath.Join(root, "state")) + t.Setenv("GITHUB_TOKEN", "host-private-live-canary") + t.Setenv("AWS_SESSION_TOKEN", "host-private-live-canary") + command := `test "$(id -u)" = 65532 +test "$(id -g)" = 65532 +if env | grep -Eq '^(GITHUB_TOKEN|AWS_SESSION_TOKEN)='; then exit 1; fi +grep -Eq '^CapEff:[[:space:]]+0000000000000000$' /proc/self/status +grep -Eq '^NoNewPrivs:[[:space:]]+1$' /proc/self/status +awk '$2 == "/" {if ($4 !~ /(^|,)ro(,|$)/) exit 1; found=1} END {if (!found) exit 1}' /proc/mounts +test "$(stat -c %a /tmp)" = 1777 +test "$(stat -c %a /work)" = 700 +test "$(stat -c %u /work)" = 65532 +test "$PWD" = /work +printf '#!/bin/sh\nexit 0\n' > /tmp/deny-execution +chmod 700 /tmp/deny-execution +if /tmp/deny-execution 2>/dev/null; then exit 1; fi +printf '%s' "$PUBLIC_VALUE" > /work/message +cat /work/message` + cfg := map[string]any{ + "modules": []any{}, + "pipelines": map[string]any{"selected": map[string]any{"steps": []any{ + map[string]any{"name": "command", "type": "step.sandbox_exec", "config": map[string]any{ + "image": image, "command": []string{"sh", "-euc", command}, "security_profile": "strict", + "memory_limit": "128m", "cpu_limit": 1, "timeout": "20s", "network": "none", "work_dir": "/work", + "tmpfs": map[string]string{"/work": "size=16m,mode=0700,uid=65532,gid=65532,noexec,nosuid,nodev"}, + "env": map[string]string{"PUBLIC_VALUE": "{{ .message }}"}, + }}, + map[string]any{"name": "result", "type": "step.set", "config": map[string]any{"values": map[string]any{ + "source": "live-container", "message": "{{ .steps.command.stdout }}", + }}}, + }}}, + } + data, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "live-sandbox.yaml") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + stdout, stderr, err := runPipelineRecordHost(t, binary, path, "", "--var", "message=live-dynamic-message") + if err != nil || string(stdout) != "SDK_RECORD_V1 {\"message\":\"live-dynamic-message\",\"source\":\"live-container\"}\n" { + t.Fatalf("built wfctl failed live sandbox lifecycle: error=%v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + assertPipelineRecordHostJournalEmpty(t, root) +} + +func pullPipelineRecordLiveImage(t *testing.T) string { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + const image = "busybox:1.37.0" + if output, err := exec.CommandContext(ctx, "docker", "pull", image).CombinedOutput(); err != nil { + t.Fatalf("prepare real CI sandbox image: %v\n%s", err, output) + } + output, err := exec.CommandContext(ctx, "docker", "image", "inspect", "--format", "{{json .RepoDigests}}", image).Output() + if err != nil { + t.Fatalf("resolve real CI sandbox image digest: %v", err) + } + var digests []string + if json.Unmarshal(output, &digests) != nil || len(digests) == 0 { + t.Fatal("real CI sandbox image has no immutable repository digest") + } + return digests[0] +} diff --git a/cmd/wfctl/pipeline_record_other.go b/cmd/wfctl/pipeline_record_other.go new file mode 100644 index 00000000..5c32f1f0 --- /dev/null +++ b/cmd/wfctl/pipeline_record_other.go @@ -0,0 +1,20 @@ +//go:build !linux && !darwin + +package main + +import ( + "errors" + "os" +) + +func runPipelineRecord(request pipelineRecordRequest, options pipelineRecordOptions) error { + if err := validatePipelineRecordSelectors(request.ResultStep, options.SuccessPrefix, options.ErrorPrefix); err != nil { + return err + } + if err := writePipelineRecord(os.Stdout, options.ErrorPrefix, pipelineRecordErrorPayload("pipeline_failed")); err != nil { + return errors.New("pipeline record output failed") + } + return errors.New("pipeline record mode is unsupported on this platform") +} + +func runPipelineRecordChild() int { return 1 } diff --git a/cmd/wfctl/pipeline_record_test.go b/cmd/wfctl/pipeline_record_test.go new file mode 100644 index 00000000..08c5ab2b --- /dev/null +++ b/cmd/wfctl/pipeline_record_test.go @@ -0,0 +1,209 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/module" + "github.com/GoCodeAlone/workflow/plugin" +) + +func TestPipelineRecordEnvelopeRejectsUnknownFields(t *testing.T) { + for _, envelope := range []map[string]any{ + {"status": "ok", "output": map[string]any{"ready": true}, "foreign": true}, + {"status": "ok", "output": "not-an-object"}, + {"status": "error", "code": "private-canary"}, + {"status": "error", "code": "pipeline_failed", "output": map[string]any{}}, + {"status": "other"}, + } { + if _, _, err := selectPipelineRecordEnvelope(envelope); err == nil { + t.Fatal("accepted an invalid child envelope") + } + } + output, code, err := selectPipelineRecordEnvelope(map[string]any{"status": "ok", "output": map[string]any{"ready": true}}) + if err != nil || code != "" || output["ready"] != true { + t.Fatalf("valid envelope denied: %#v, %q, %v", output, code, err) + } +} + +func TestPipelineRecordSelectorsRejectWhitespaceAndControl(t *testing.T) { + for _, result := range []string{"result\x00", "result\v", "result\f", "result\x7f", "result\u0085", "result\u00a0", "result\u2003"} { + t.Run(result, func(t *testing.T) { + if err := validatePipelineRecordSelectors(result, "FIXTURE_V1", "FIXTURE_ERROR_V1"); err == nil { + t.Fatal("accepted whitespace/control in a result-step selector") + } + }) + } + if err := validatePipelineRecordSelectors("r\u00e9sultat", "FIXTURE_V1", "FIXTURE_ERROR_V1"); err != nil { + t.Fatalf("literal Unicode name denied: %v", err) + } +} + +func TestPipelineRecordRuntimeTypeOwnership(t *testing.T) { + manifest := &plugin.PluginManifest{Name: "fixture", StepTypes: []string{"step.fixture"}} + for _, factories := range []map[string]plugin.StepFactory{ + {"step.fixture": nil, "step.set": nil}, {}, {"step.other": nil}, + } { + if err := validatePipelineRuntimeTypes(manifest, factories); err == nil { + t.Fatal("runtime factories bypassed manifest-only ownership") + } + } + if err := validatePipelineRuntimeTypes(manifest, map[string]plugin.StepFactory{"step.fixture": nil}); err != nil { + t.Fatal(err) + } +} + +func TestPipelineRecordExecutesActualEngine(t *testing.T) { + cfg, err := config.LoadFromBytes([]byte(` +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.set + config: + values: {message: "{{ .message }}"} +`)) + if err != nil { + t.Fatal(err) + } + request := pipelineRecordRequest{Config: cfg, Pipeline: "selected", ResultStep: "result", + Input: map[string]any{"message": "dynamic-message"}} + envelope := executePipelineRecord(context.Background(), request) + output, ok := envelope["output"].(map[string]any) + if envelope["status"] != "ok" || !ok || output["message"] != "dynamic-message" { + t.Fatalf("record backend did not execute the configured Workflow engine: %#v", envelope) + } + request.ResultStep = "missing" + envelope = executePipelineRecord(context.Background(), request) + if envelope["status"] != "error" || envelope["code"] != "result_invalid" { + t.Fatalf("unreachable selector was accepted: %#v", envelope) + } +} + +func TestPipelineRecordRejectsSkippedPipelineErrors(t *testing.T) { + cfg, err := config.LoadFromBytes([]byte(` +modules: [] +pipelines: + selected: + on_error: skip + steps: + - name: invalid + type: step.validate + config: + required_fields: [missing] + - name: result + type: step.set + config: + values: {ready: true} +`)) + if err != nil { + t.Fatal(err) + } + envelope := executePipelineRecord(context.Background(), pipelineRecordRequest{Config: cfg, Pipeline: "selected", ResultStep: "result"}) + if envelope["status"] != "error" { + t.Fatalf("a failed step followed by a result produced a successful record: %#v", envelope) + } + if _, err := selectPipelineClosure(cfg, "selected", false); err != nil { + t.Fatalf("record-mode error-strategy restriction changed human preflight: %v", err) + } +} + +func TestWorkflowCanonicalJSONV1(t *testing.T) { + value := map[string]any{ + "z": []any{nil, true, false, "9007199254740993"}, + "a": map[string]any{"text": "<>&\n\t\"\\", "unicode": "\u00e9"}, + } + want := `{"a":{"text":"<>&\n\t\"\\","unicode":"` + "\u00e9" + `"},"z":[null,true,false,"9007199254740993"]}` + got, err := workflowCanonicalJSONV1(value) + if err != nil || string(got) != want { + t.Fatalf("canonical bytes = %q, %v; want %q", got, err, want) + } +} + +func TestWorkflowCanonicalJSONV1RejectsInvalidValues(t *testing.T) { + for _, value := range []any{ + 1, int64(9007199254740993), float64(1), json.Number("9007199254740993"), + "\xff", map[string]any{"\xff": "value"}, []any{map[string]any{"number": 1}}, + make(chan int), []byte("bytes"), strings.Repeat("x", 1<<20), + } { + if _, err := workflowCanonicalJSONV1(value); err == nil { + t.Errorf("accepted invalid record value %T", value) + } + } +} + +func TestPipelineResultFrameRejectsAmbiguousFrames(t *testing.T) { + valid, err := encodePipelineResultFrame(map[string]any{"status": "ok", "output": map[string]any{"ready": true}}) + if err != nil { + t.Fatal(err) + } + for name, frame := range map[string][]byte{ + "empty": nil, + "truncated header": valid[:4], + "truncated body": valid[:len(valid)-1], + "trailing byte": append(bytes.Clone(valid), ' '), + "duplicate frame": append(bytes.Clone(valid), valid...), + "bad magic": append([]byte("NOPE"), valid[4:]...), + "oversized length": append([]byte("WFR1\xff\xff\xff\xff"), valid[8:]...), + } { + t.Run(name, func(t *testing.T) { + if _, err := decodePipelineResultFrame(frame); err == nil { + t.Fatal("accepted ambiguous result channel") + } + }) + } + if _, err := decodePipelineResultFrame(valid); err != nil { + t.Fatal(err) + } +} + +type pipelineStoppedErrorFixture struct{} + +func (pipelineStoppedErrorFixture) Name() string { return "stopped-error" } + +func (pipelineStoppedErrorFixture) Execute(context.Context, *module.PipelineContext) (*module.StepResult, error) { + return &module.StepResult{Output: map[string]any{"error": "private-canary"}, Stop: true}, nil +} + +func TestPipelineProgressRejectsStoppedError(t *testing.T) { + pipeline := &module.Pipeline{Name: "fixture", Steps: []module.PipelineStep{pipelineStoppedErrorFixture{}}} + _, err := executePipelineWithProgress(context.Background(), pipeline, nil, false) + if err == nil { + t.Fatal("a stopped error must not be reported as pipeline success") + } + if strings.Contains(err.Error(), "private-canary") { + t.Fatalf("error output must not be reflected: %v", err) + } +} + +func TestPipelineRunRecordFlagIsRecognized(t *testing.T) { + stateHome, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("PATH", t.TempDir()) + path := writePipelineConfig(t, t.TempDir(), "application.yaml", ` +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.set + config: + values: {ready: "true"} +`) + _, err = captureStdout(t, func() error { + return runPipelineRun([]string{"-c", path, "-p", "selected", "--output", "record", + "--result-step", "result", "--record-prefix", "FIXTURE_V1", "--error-prefix", "FIXTURE_ERROR_V1"}) + }) + if err != nil && strings.Contains(err.Error(), "flag provided but not defined") { + t.Fatalf("record-mode contract flags are missing: %v", err) + } +} diff --git a/cmd/wfctl/pipeline_record_unix.go b/cmd/wfctl/pipeline_record_unix.go new file mode 100644 index 00000000..79c6b204 --- /dev/null +++ b/cmd/wfctl/pipeline_record_unix.go @@ -0,0 +1,499 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/sandbox" + "golang.org/x/sys/unix" +) + +var errPipelineCleanupIncomplete = errors.New("pipeline cleanup is incomplete") + +type pipelineRecordCapture struct { + data bytes.Buffer + limit int + overflow bool + err error + stdout bool + violation func() +} + +func (capture *pipelineRecordCapture) Write(data []byte) (int, error) { + length := len(data) + remaining := capture.limit - capture.data.Len() + if len(data) > remaining { + capture.overflow = true + data = data[:remaining] + } + _, _ = capture.data.Write(data) + if (capture.overflow || (capture.stdout && length != 0)) && capture.violation != nil { + capture.violation() + } + return length, nil +} + +func runPipelineRecord(request pipelineRecordRequest, options pipelineRecordOptions) error { + if err := validatePipelineRecordSelectors(request.ResultStep, options.SuccessPrefix, options.ErrorPrefix); err != nil { + return err + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + ctx, deadlineCancel := context.WithTimeout(ctx, 10*time.Minute) + defer deadlineCancel() + output, code := executePipelineRecordParent(ctx, request, options.ConfigPath) + if code != "" { + if err := writePipelineRecord(os.Stdout, options.ErrorPrefix, pipelineRecordErrorPayload(code)); err != nil { + return errors.New("pipeline record output failed") + } + return errors.New("pipeline record execution failed") + } + if err := writePipelineRecord(os.Stdout, options.SuccessPrefix, output); err != nil { + return errors.New("pipeline record output failed") + } + return nil +} + +func pipelineCleanupRoot() (string, error) { + base := os.Getenv("XDG_STATE_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".local", "state") + } + if !filepath.IsAbs(base) { + return "", fmt.Errorf("pipeline cleanup state must be absolute") + } + return filepath.Join(base, "wfctl", "pipeline-cleanup"), nil +} + +func executePipelineRecordParent(ctx context.Context, request pipelineRecordRequest, configPath string) (map[string]any, string) { + fail := func() (map[string]any, string) { return nil, "pipeline_failed" } + root, err := pipelineCleanupRoot() + if err != nil { + return fail() + } + state, err := openPipelineCleanupState(root) + if err != nil { + return fail() + } + defer state.Close() + retained, err := state.Entries() + if err != nil { + return fail() + } + // Kill reuse-safe retained process groups before contacting any daemon or + // loading config. A daemon outage must not leave an old child doing work. + for i := range retained { + entry := retained[i] + if reconcilePipelineProcesses(entry) != nil { + return fail() + } + } + client, err := resolvePipelineDockerClient(ctx) + if err != nil { + return fail() + } + defer client.Close() + for i := range retained { + entry := retained[i] + if entry.Docker != client.Identity || finishPipelineCleanup(state, client, entry) != nil { + return fail() + } + } + request.Config, err = config.LoadFromFile(configPath) + if err != nil { + return fail() + } + closure, err := selectPipelineClosure(request.Config, request.Pipeline, true) + if err != nil || closure.names[request.ResultStep] != 1 { + return nil, "result_invalid" + } + installed, err := inspectPipelineInstallations(request.PluginDir) + if err != nil { + return fail() + } + if _, err := installed.resolve(closure.types); err != nil { + return fail() + } + request.Config = closure.config + parentToken, err := pipelineProcessStartToken(os.Getpid()) + if err != nil { + return fail() + } + request.ParentPID, request.ParentStart = os.Getpid(), parentToken + data, err := json.Marshal(request) + if err != nil || len(data) > maxPipelineRecordBytes { + return nil, "result_invalid" + } + var nonce [16]byte + if _, err := rand.Read(nonce[:]); err != nil { + return fail() + } + deadline, _ := ctx.Deadline() + entry := pipelineCleanupEntry{Version: 1, Label: "wfctl-" + hex.EncodeToString(nonce[:]), + Parent: pipelineProcessIdentity{PID: os.Getpid(), Start: parentToken}, Docker: client.Identity, + Deadline: deadline.UTC().Format(time.RFC3339Nano)} + if err := state.Save(entry); err != nil { + return fail() + } + envelope, childErr := executeIsolatedPipelineChild(ctx, request, data, state, client, &entry) + if errors.Is(childErr, errPipelineCleanupIncomplete) { + return fail() + } + if err := finishPipelineCleanup(state, client, entry); err != nil { + return fail() + } + if childErr != nil { + return fail() + } + output, code, err := selectPipelineRecordEnvelope(envelope) + if err != nil { + return nil, "result_invalid" + } + return output, code +} + +func finishPipelineCleanup(state *pipelineCleanupState, client *pipelineDockerClient, entry pipelineCleanupEntry) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if entry.Docker != client.Identity { + return fmt.Errorf("pipeline cleanup daemon identity changed") + } + if err := client.Cleanup(ctx, entry); err != nil { + return err + } + for _, name := range entry.CIDFiles { + path := filepath.Join(state.root, name) + file, err := openPipelinePrivateFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + if err := os.Remove(path); err != nil { + return err + } + } + if err := state.sync(); err != nil { + return err + } + return state.Delete(entry.Label) +} + +func executeIsolatedPipelineChild(ctx context.Context, request pipelineRecordRequest, data []byte, state *pipelineCleanupState, client *pipelineDockerClient, entry *pipelineCleanupEntry) (map[string]any, error) { + var files []*os.File + defer func() { + for _, file := range files { + _ = file.Close() + } + }() + pipe := func() (*os.File, *os.File, error) { + read, write, err := os.Pipe() + if err == nil { + files = append(files, read, write) + } + return read, write, err + } + inputRead, inputWrite, err := pipe() + if err != nil { + return nil, err + } + resultRead, resultWrite, err := pipe() + if err != nil { + return nil, err + } + controlRead, controlWrite, err := pipe() + if err != nil { + return nil, err + } + stdoutRead, stdoutWrite, err := pipe() + if err != nil { + return nil, err + } + stderrRead, stderrWrite, err := pipe() + if err != nil { + return nil, err + } + sockets, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + return nil, err + } + for _, fd := range sockets { + unix.CloseOnExec(fd) + } + brokerParent := os.NewFile(uintptr(sockets[0]), "parent sandbox broker") // #nosec G115 -- successful Socketpair returns nonnegative descriptors. + brokerChild := os.NewFile(uintptr(sockets[1]), "child sandbox broker") // #nosec G115 -- successful Socketpair returns nonnegative descriptors. + files = append(files, brokerParent, brokerChild) + brokerConn, err := net.FileConn(brokerParent) + if err != nil { + return nil, err + } + _ = brokerParent.Close() + defer brokerConn.Close() + executable, err := os.Executable() + if err != nil { + return nil, err + } + child := exec.Command(executable, pipelineRecordChildCommand) // #nosec G204 -- own executable; no application data in argv. + child.Env = pipelineRecordChildEnvironment(os.Environ(), filepath.Join(state.root, entry.Label+".tools")) + child.ExtraFiles = []*os.File{inputRead, resultWrite, controlRead, brokerChild} + child.Stdout, child.Stderr = stdoutWrite, stderrWrite + child.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := child.Start(); err != nil { + return nil, err + } + for _, file := range []*os.File{inputRead, resultWrite, controlRead, brokerChild, stdoutWrite, stderrWrite} { + _ = file.Close() + } + childToken, err := pipelineProcessStartToken(child.Process.Pid) + if err != nil { + _ = child.Process.Kill() + _ = child.Wait() + return nil, err + } + entry.Child = pipelineProcessIdentity{PID: child.Process.Pid, PGID: child.Process.Pid, Start: childToken} + if err := state.Save(*entry); err != nil { + _ = child.Process.Kill() + _ = child.Wait() + return nil, err + } + violation := make(chan struct{}, 1) + notifyViolation := func() { + select { + case violation <- struct{}{}: + default: + } + } + stdout := &pipelineRecordCapture{limit: maxPipelineRecordBytes, stdout: true, violation: notifyViolation} + stderr := &pipelineRecordCapture{limit: maxPipelineRecordBytes, violation: notifyViolation} + result := &pipelineRecordCapture{limit: maxPipelineRecordBytes + 8, violation: notifyViolation} + drain := func(reader *os.File, capture *pipelineRecordCapture) <-chan struct{} { + done := make(chan struct{}) + go func() { _, capture.err = io.Copy(capture, reader); close(done) }() + return done + } + stdoutDone, stderrDone, resultDone := drain(stdoutRead, stdout), drain(stderrRead, stderr), drain(resultRead, result) + brokerCtx, brokerCancel := context.WithCancel(ctx) + defer brokerCancel() + brokerDone := make(chan error, 1) + go func() { + brokerDone <- servePipelineBroker(brokerCtx, brokerConn, func(ctx context.Context, request pipelineBrokerRequest) (*sandbox.ExecResult, error) { + return executePipelineBrokerSandbox(ctx, request, state, client, entry) + }) + }() + inputDone := make(chan error, 1) + go func() { + _, err := inputWrite.Write(data) + closeErr := inputWrite.Close() + inputDone <- errors.Join(err, closeErr) + }() + var protocolErr error + brokerStopped := false + select { + case <-resultDone: + case protocolErr = <-brokerDone: + brokerStopped = true + if protocolErr == nil { + protocolErr = errors.New("sandbox broker closed before result") + } + case <-ctx.Done(): + protocolErr = errors.New("pipeline record cancelled") + case <-violation: + protocolErr = errors.New("pipeline child output quarantine failed") + } + _ = controlWrite.Close() + brokerCancel() + _ = brokerConn.Close() + groupErr := stopPipelineProcessGroup(entry.Child, 5*time.Second) + // The leader is deliberately not reaped before escalation, so its PID/PGID + // cannot be recycled while TERM/KILL still targets the recorded group. + if groupErr != nil { + _ = child.Process.Kill() + } + waitErr := child.Wait() + if waitErr != nil && protocolErr == nil { + protocolErr = errors.New("pipeline child failed") + } + // Every drain has a bounded termination deadline even if a nonconforming + // native plugin escaped its group or retained a pipe descriptor. + for _, done := range []<-chan struct{}{stdoutDone, stderrDone, resultDone} { + select { + case <-done: + case <-time.After(time.Second): + _ = stdoutRead.Close() + _ = stderrRead.Close() + _ = resultRead.Close() + <-done + protocolErr = errors.New("pipeline child pipe did not close") + } + } + _ = inputWrite.Close() + if inputErr := <-inputDone; inputErr != nil { + protocolErr = errors.New("pipeline child input failed") + } + if !brokerStopped { + select { + case <-brokerDone: + case <-time.After(31 * time.Second): + return nil, errPipelineCleanupIncomplete + } + } + if groupErr != nil { + return nil, errPipelineCleanupIncomplete + } + if err := waitPipelineProcessGroupEmpty(entry.Child.PGID, time.Second); err != nil { + return nil, errPipelineCleanupIncomplete + } + if stdout.err != nil || stderr.err != nil || result.err != nil || stdout.data.Len() != 0 || stdout.overflow || stderr.overflow || result.overflow { + return nil, errors.New("pipeline child quarantine or cleanup failed") + } + if protocolErr != nil { + return nil, protocolErr + } + return decodePipelineResultFrame(result.data.Bytes()) +} + +func waitPipelineProcessGroupEmpty(pgid int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + err := unix.Kill(-pgid, 0) + if errors.Is(err, unix.ESRCH) { + return nil + } + if err != nil { + return err + } + if time.Now().After(deadline) { + return errors.New("pipeline process group is not empty") + } + time.Sleep(25 * time.Millisecond) + } +} + +func executePipelineBrokerSandbox(ctx context.Context, request pipelineBrokerRequest, state *pipelineCleanupState, client *pipelineDockerClient, entry *pipelineCleanupEntry) (*sandbox.ExecResult, error) { + name := fmt.Sprintf("%s-%d.cid", entry.Label, len(entry.CIDFiles)) + args, err := client.CreateArgs(request.Config, request.Command, entry.Label, filepath.Join(state.root, name)) + if err != nil { + return nil, err + } + entry.CIDFiles = append(entry.CIDFiles, name) + if err := state.Save(*entry); err != nil { + return nil, err + } + created, err := client.RunInProcessGroup(ctx, entry.Child, append([]string{"create"}, args...)...) + if err != nil || created == nil || created.ExitCode != 0 { + return nil, errors.New("record sandbox create failed") + } + id := strings.TrimSpace(created.Stdout) + if len(id) != 64 || strings.ToLower(id) != id { + return nil, errors.New("invalid record sandbox ID") + } + if _, err := hex.DecodeString(id); err != nil { + return nil, errors.New("invalid record sandbox ID") + } + entry.IDs = append(entry.IDs, id) + if err := state.Save(*entry); err != nil { + return nil, err + } + result, err := client.RunInProcessGroup(ctx, entry.Child, "start", "-a", id) + if err != nil || result == nil { + return nil, errors.New("record sandbox attach failed") + } + wait, err := client.RunInProcessGroup(ctx, entry.Child, "wait", id) + if err != nil || wait == nil || wait.ExitCode != 0 { + return nil, errors.New("record sandbox wait failed") + } + code, err := strconv.Atoi(strings.TrimSpace(wait.Stdout)) + if err != nil || code < 0 || code > 255 { + return nil, errors.New("invalid record sandbox status") + } + result.ExitCode = code + return result, nil +} + +func reconcilePipelineProcesses(entry pipelineCleanupEntry) error { + if token, err := pipelineProcessStartToken(entry.Parent.PID); err == nil { + if token != entry.Parent.Start { + return fmt.Errorf("pipeline cleanup parent PID was reused") + } + return fmt.Errorf("recorded pipeline parent is still active") + } else if !pipelineProcessAbsent(err) { + return err + } + if entry.Child.PID == 0 { + return nil + } + if err := stopPipelineProcessGroup(entry.Child, 5*time.Second); err != nil { + return err + } + return waitPipelineProcessGroupEmpty(entry.Child.PGID, time.Second) +} + +func pipelineProcessAbsent(err error) bool { + return errors.Is(err, os.ErrNotExist) || errors.Is(err, unix.ESRCH) +} + +func stopPipelineProcessGroup(process pipelineProcessIdentity, grace time.Duration) error { + if process.PID <= 0 || process.PGID != process.PID || process.Start == "" { + return fmt.Errorf("invalid cleanup process group") + } + if token, err := pipelineProcessStartToken(process.PID); err == nil { + if token != process.Start { + return fmt.Errorf("pipeline cleanup child PID was reused") + } + } else if !pipelineProcessAbsent(err) { + return err + } + for _, signal := range []unix.Signal{0, unix.SIGTERM, unix.SIGKILL} { + deadline := time.Now().Add(grace) + for { + active, err := pipelineProcessGroupActive(process.PGID) + if err != nil { + return err + } + if !active { + return nil + } + token, err := pipelineProcessStartToken(process.PID) + if err != nil || token != process.Start { + return fmt.Errorf("cleanup process-group identity cannot be proven") + } + if signal != 0 { + if err := unix.Kill(-process.PGID, signal); err != nil && !errors.Is(err, unix.ESRCH) { + return err + } + signal = 0 + } + if time.Now().After(deadline) { + break + } + time.Sleep(25 * time.Millisecond) + } + } + return fmt.Errorf("pipeline process group survived cleanup") +} diff --git a/cmd/wfctl/pipeline_record_unix_test.go b/cmd/wfctl/pipeline_record_unix_test.go new file mode 100644 index 00000000..e3371be0 --- /dev/null +++ b/cmd/wfctl/pipeline_record_unix_test.go @@ -0,0 +1,51 @@ +//go:build linux || darwin + +package main + +import ( + "os" + "slices" + "strings" + "testing" +) + +func TestPipelineCleanupRejectsReusedPIDBeforeEmptyGroup(t *testing.T) { + identity := pipelineProcessIdentity{PID: os.Getpid(), PGID: os.Getpid(), Start: "wrong-start-token"} + if err := stopPipelineProcessGroup(identity, 0); err == nil { + t.Fatal("an empty historical group hid a reused PID") + } +} + +func TestPipelineRecordCaptureImmediatelyReportsQuarantine(t *testing.T) { + for _, stdout := range []bool{true, false} { + signalled := 0 + capture := pipelineRecordCapture{limit: 3, stdout: stdout, violation: func() { signalled++ }} + if count, err := capture.Write([]byte("ok")); count != 2 || err != nil { + t.Fatalf("capture did not drain: %d, %v", count, err) + } + if stdout && signalled == 0 { + t.Fatal("unexpected stdout did not interrupt the run") + } + if !stdout && signalled != 0 { + t.Fatal("bounded diagnostics were treated as a violation") + } + if count, err := capture.Write([]byte("more")); count != 4 || err != nil { + t.Fatalf("overflow capture stopped draining: %d, %v", count, err) + } + if !capture.overflow || capture.data.Len() != 3 || signalled == 0 { + t.Fatal("overflow did not interrupt the run with bounded retained bytes") + } + } +} + +func TestPipelineRecordChildHasNoAmbientDockerAuthority(t *testing.T) { + env := pipelineRecordChildEnvironment([]string{"PATH=/host/tools", "DOCKER_HOST=tcp://daemon-A:2376", "DOCKER_CONTEXT=private", "DOCKER_CERT_PATH=/private/certs", "DOCKER_TLS_VERIFY=1", "DOCKER_CONFIG=/private/docker", "PUBLIC_VALUE=selected"}, "/private/record-tools") + for _, variable := range env { + if strings.HasPrefix(variable, "DOCKER_") && variable != "DOCKER_HOST=unix:///wfctl-record-mode-daemon-denied.sock" && variable != "DOCKER_CONFIG=/private/record-tools" { + t.Fatalf("child inherited Docker authority: %q", variable) + } + } + if !slices.Contains(env, "PATH=/private/record-tools") || !slices.Contains(env, "PUBLIC_VALUE=selected") { + t.Fatalf("child tool boundary not applied: %#v", env) + } +} diff --git a/cmd/wfctl/pipeline_test.go b/cmd/wfctl/pipeline_test.go index 03d2c246..2e7626d1 100644 --- a/cmd/wfctl/pipeline_test.go +++ b/cmd/wfctl/pipeline_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "path/filepath" "strings" @@ -248,6 +249,37 @@ func TestRunPipelineRunWithInvalidInputJSON(t *testing.T) { } } +func TestRunPipelineRunRejectsNullInput(t *testing.T) { + stateHome, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Setenv("XDG_STATE_HOME", stateHome) + t.Setenv("PATH", t.TempDir()) + for _, mode := range []string{"human", "record"} { + for _, vars := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/vars=%t", mode, vars), func(t *testing.T) { + defer func() { + if recover() != nil { + t.Error("null JSON input panicked before application load") + } + }() + args := []string{"-c", filepath.Join(t.TempDir(), "must-not-read.yaml"), "-p", "selected", "--input", "null", "--output", mode} + if mode == "record" { + args = append(args, "--result-step", "result", "--record-prefix", "FIXTURE_V1", "--error-prefix", "FIXTURE_ERROR_V1") + } + if vars { + args = append(args, "--var", "marker=private-null-canary") + } + err := runPipelineRun(args) + if err == nil || !strings.Contains(err.Error(), "input") || strings.Contains(err.Error(), "private-null-canary") { + t.Errorf("null input was not rejected safely before loading config: %v", err) + } + }) + } + } +} + func TestRunPipelineRunWithInvalidVar(t *testing.T) { dir := t.TempDir() path := writePipelineConfig(t, dir, "config.yaml", pipelineSingleConfig) diff --git a/cmd/wfctl/plugin_registry_sync.go b/cmd/wfctl/plugin_registry_sync.go index fcbe95bf..beb17bb3 100644 --- a/cmd/wfctl/plugin_registry_sync.go +++ b/cmd/wfctl/plugin_registry_sync.go @@ -51,6 +51,8 @@ func runPluginRegistrySync(args []string) error { pluginFilter := fs.String("plugin", "", "Restrict to single plugin directory name") verifyCaps := fs.Bool("verify-capabilities", false, "Spawn binary + diff capabilities (registry-side; slow)") registryDir := fs.String("registry-dir", ".", "Path to a workflow-registry checkout") + targetVersion := fs.String("target-version", "", "Exact stable plugin version (including an older version for rollback)") + report := fs.String("report", "", "Write a canonical registry-sync-operation.v1 report (requires --plugin and --fix)") fs.Usage = func() { fmt.Fprintf(fs.Output(), `Usage: wfctl plugin registry-sync [options] wfctl plugin registry-sync core [options] @@ -74,6 +76,35 @@ Options: if err := fs.Parse(args); err != nil { return err } + var exactTarget, reportRequested bool + fs.Visit(func(f *flag.Flag) { + if f.Name == "target-version" { + exactTarget = true + } + if f.Name == "report" { + reportRequested = true + } + }) + if exactTarget || reportRequested { + if fs.NArg() != 0 { + return fmt.Errorf("unexpected registry-sync arguments: %v", fs.Args()) + } + tag := "" + if exactTarget { + var err error + tag, err = registrySyncStableTag(*targetVersion) + if err != nil { + return err + } + } + if reportRequested && (*report == "" || !*fix) { + return fmt.Errorf("--report requires a nonempty file path and --fix") + } + if !reportRequested { + return syncRegistryTarget(*registryDir, *pluginFilter, *targetVersion, *fix, *verifyCaps) + } + return syncRegistryRelease(*registryDir, *pluginFilter, tag, *report, *fix, *verifyCaps) + } return syncDefault(*registryDir, *fix, *pluginFilter, *verifyCaps) } @@ -295,13 +326,21 @@ type releaseAsset struct { } type githubReleaseAsset struct { + ID int64 `json:"id"` Name string `json:"name"` + Size int64 `json:"size"` + Digest string `json:"digest"` BrowserDownloadURL string `json:"browser_download_url"` } type githubReleaseMetadata struct { - TagName string `json:"tag_name"` - Assets []githubReleaseAsset `json:"assets"` + ID int64 `json:"id"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + Immutable bool `json:"immutable"` + Assets []githubReleaseAsset `json:"assets"` } func escapedGitHubRepoPath(ghRepo string) string { @@ -317,12 +356,20 @@ func githubReleaseByTag(ghRepo, tag string) (*githubReleaseMetadata, error) { } func fetchGitHubReleaseMetadata(apiPath string) (*githubReleaseMetadata, error) { + var release githubReleaseMetadata + if err := registrySyncGitHubJSON(apiPath, &release); err != nil { + return nil, err + } + return &release, nil +} + +func registrySyncGitHubJSON(apiPath string, result any) error { apiURL := strings.TrimRight(gitHubAPIBaseURL, "/") + "/" + strings.TrimLeft(apiPath, "/") ctx, cancel := context.WithTimeout(context.Background(), gitHubReleaseMetadataTimeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) //nolint:gosec // URL is built from trusted repo/tag metadata. if err != nil { - return nil, err + return err } if tok := gitHubToken(); tok != "" { req.Header.Set("Authorization", "Bearer "+tok) @@ -334,18 +381,21 @@ func fetchGitHubReleaseMetadata(apiPath string) (*githubReleaseMetadata, error) resp, err := gitHubAPIClient.Do(req) if err != nil { closeResponseBody(resp) - return nil, fmt.Errorf("GitHub releases API request %s: %w", apiPath, err) + return fmt.Errorf("GitHub API request %s: %w", apiPath, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return nil, fmt.Errorf("GitHub releases API: HTTP %d for %s: %s", resp.StatusCode, apiPath, strings.TrimSpace(string(body))) + return fmt.Errorf("GitHub API: HTTP %d for %s: %s", resp.StatusCode, apiPath, strings.TrimSpace(string(body))) } - var release githubReleaseMetadata - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { - return nil, fmt.Errorf("decode GitHub release response: %w", err) + decoder := json.NewDecoder(io.LimitReader(resp.Body, 8<<20)) + if err := decoder.Decode(result); err != nil { + return fmt.Errorf("decode GitHub API response: %w", err) } - return &release, nil + if err := decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("GitHub API response has trailing data: %v", err) + } + return nil } // releaseDownloads returns the platform release-asset list for a tag, in the diff --git a/cmd/wfctl/plugin_registry_sync_host_negatives_test.go b/cmd/wfctl/plugin_registry_sync_host_negatives_test.go new file mode 100644 index 00000000..d7892412 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_host_negatives_test.go @@ -0,0 +1,360 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io/fs" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "syscall" + "testing" + "time" +) + +type registrySyncHostFileState struct { + info os.FileInfo + data []byte +} + +type registrySyncHostRegistryState struct { + files map[string]registrySyncHostFileState + head, index, status []byte +} + +func registrySyncHostReadState(t *testing.T, f *registrySyncTargetFixture, watched ...string) registrySyncHostRegistryState { + t.Helper() + state := registrySyncHostRegistryState{files: make(map[string]registrySyncHostFileState)} + read := func(path string) { + t.Helper() + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + state.files[path] = registrySyncHostFileState{} + return + } + if err != nil { + t.Fatal(err) + } + file := registrySyncHostFileState{info: info} + switch { + case info.Mode().IsRegular(): + file.data, err = os.ReadFile(path) + case info.Mode()&os.ModeSymlink != 0: + var target string + target, err = os.Readlink(path) + file.data = []byte(target) + } + if err != nil { + t.Fatal(err) + } + state.files[path] = file + } + if err := filepath.WalkDir(f.root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if path == filepath.Join(f.root, ".git") { + return filepath.SkipDir + } + read(path) + return nil + }); err != nil { + t.Fatal(err) + } + for _, path := range watched { + if !filepath.IsAbs(path) { + path = filepath.Join(f.root, path) + } + read(path) + } + state.head = registryReportGit(t, f.root, "rev-parse", "HEAD") + state.index = registryReportGit(t, f.root, "ls-files", "--stage", "-z") + state.status = registryReportGit(t, f.root, "status", "--porcelain=v1", "--untracked-files=all", "-z") + return state +} + +func registrySyncHostAssertUnchanged(t *testing.T, before, after registrySyncHostRegistryState) { + t.Helper() + if len(before.files) != len(after.files) { + t.Errorf("denied CLI changed the registry/report path set: before=%d after=%d", len(before.files), len(after.files)) + } + for path, old := range before.files { + current, ok := after.files[path] + if !ok || (old.info == nil) != (current.info == nil) { + t.Errorf("denied CLI created or removed %s", path) + continue + } + if old.info != nil && (old.info.Mode() != current.info.Mode() || !os.SameFile(old.info, current.info) || !bytes.Equal(old.data, current.data)) { + t.Errorf("denied CLI changed bytes, mode or file identity: %s", path) + } + } + for _, field := range []struct { + name string + before, after []byte + }{ + {"HEAD", before.head, after.head}, + {"index entries", before.index, after.index}, + {"worktree status", before.status, after.status}, + } { + if !bytes.Equal(field.before, field.after) { + t.Errorf("denied CLI changed registry %s", field.name) + } + } +} + +func TestPluginRegistrySyncHostNegatives(t *testing.T) { + // One transport and binary serve independent, sequential registry fixtures. + var active atomic.Pointer[registrySyncTargetFixture] + var requests atomic.Int32 + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixture := active.Load() + if fixture == nil { + http.Error(w, "no active registry fixture", http.StatusServiceUnavailable) + return + } + requests.Add(1) + fixture.handler.ServeHTTP(w, r) + }) + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + binary := registrySyncActualCLIBinary(t, runtime.GOOS, server.URL) + env := registrySyncHostEnvironment(os.Environ()) + var proxy *registrySyncReleaseProxy + if runtime.GOOS == "linux" { + proxy = newRegistrySyncReleaseProxy(t, handler) + env = append(env, "HTTPS_PROXY="+proxy.url, "NO_PROXY=127.0.0.1,localhost", "SSL_CERT_FILE="+proxy.caFile, "SSL_CERT_DIR="+proxy.caDirectory) + } + fixture := func(t *testing.T) (*registrySyncTargetFixture, string) { + t.Helper() + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + return f, filepath.Join(f.root, "operation.json") + } + run := func(t *testing.T, f *registrySyncTargetFixture, target, plugin, report string) ([]byte, error) { + t.Helper() + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, binary, "plugin", "registry-sync", "--registry-dir", f.root, "--plugin", plugin, "--target-version", target, "--fix", "--report", report) + cmd.Dir, cmd.Env = f.root, env + cmd.WaitDelay = time.Second + active.Store(f) + out, err := cmd.CombinedOutput() + if ctx.Err() != nil { + t.Fatalf("CLI deadline is not a validation denial: %v\n%s", ctx.Err(), out) + } + if errors.Is(err, exec.ErrWaitDelay) { + t.Fatalf("CLI capture drain did not finish: %v\n%s", err, out) + } + return out, err + } + seedReport := func(t *testing.T, f *registrySyncTargetFixture, report string) { + t.Helper() + base := registryReportFixtureTree(t, f, report) + if out, err := run(t, f, "2.0.0", "foo", report); err != nil { + t.Fatalf("actual CLI control failed before negative cases: %v\n%s", err, out) + } + assertRegistryReportBinding(t, f, report, base, "1.0.0", "2.0.0", true) + } + control, controlReport := fixture(t) + seedReport(t, control, controlReport) + if proxy != nil && (proxy.connects.Load() == 0 || proxy.requests.Load() == 0) { + t.Fatal("actual Linux control bypassed the default-api.github.com CONNECT/TLS fixture") + } + reject := func(t *testing.T, f *registrySyncTargetFixture, target, plugin, report, want string, network bool, watched ...string) { + t.Helper() + watched = append(watched, report) + before := registrySyncHostReadState(t, f, watched...) + beforeRequests := requests.Load() + out, err := run(t, f, target, plugin, report) + var exit *exec.ExitError + if !errors.As(err, &exit) || exit.ExitCode() != 1 || !strings.Contains(string(out), want) { + t.Errorf("expected actual CLI denial containing %q, got %v\n%s", want, err, out) + } + if gotNetwork := requests.Load() > beforeRequests; gotNetwork != network { + t.Errorf("denial reached incorrect validation phase: GitHub requests=%t, want=%t", gotNetwork, network) + } + if f.latest.Load() != 0 { + t.Error("exact target denial consulted floating latest") + } + registrySyncHostAssertUnchanged(t, before, registrySyncHostReadState(t, f, watched...)) + } + + t.Run("mutation", func(t *testing.T) { + for _, kind := range []string{"tag", "source", "release", "asset", "checksum", "metadata"} { + t.Run(kind, func(t *testing.T) { + f, report := fixture(t) + var changed atomic.Int32 + f.mutate = func(path string, n int, out map[string]any) string { + if n != 2 { + return "" + } + switch { + case kind == "tag" && strings.Contains(path, "/git/ref/"): + out["object"].(map[string]any)["sha"] = strings.Repeat("d", 40) + case kind == "source" && strings.Contains(path, "/git/tags/"): + out["object"].(map[string]any)["sha"] = strings.Repeat("d", 40) + case kind == "release" && strings.Contains(path, "/releases/tags/"): + out["id"] = 101 + case kind == "asset" && strings.Contains(path, "/releases/tags/"): + out["assets"].([]any)[1].(map[string]any)["id"] = 202 + case kind == "checksum" && strings.HasPrefix(path, "/checksums/"): + changed.Add(1) + return strings.Repeat("e", 64) + " workflow-plugin-foo-linux-amd64.tar.gz\n" + case kind == "metadata" && strings.HasSuffix(path, "/plugin.json"): + out["content"] = base64.StdEncoding.EncodeToString([]byte(`{"type":"external","capabilities":{"services":["changed"]}}`)) + default: + return "" + } + changed.Add(1) + return "" + } + // Keep changed objects readable so rejection requires snapshot comparison. + original := f.handler + f.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if kind == "tag" && r.URL.Path == "/repos/owner/repo/git/tags/"+strings.Repeat("d", 40) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]any{ + "sha": strings.Repeat("d", 40), "object": map[string]any{"type": "commit", "sha": strings.Repeat("a", 40)}, + }); err != nil { + t.Error(err) + } + return + } + if kind == "source" && r.URL.Path == "/repos/owner/repo/contents/plugin.json" && r.URL.Query().Get("ref") == strings.Repeat("d", 40) { + r = r.Clone(r.Context()) + r.URL.RawQuery = "ref=" + strings.Repeat("a", 40) + } + original.ServeHTTP(w, r) + }) + reject(t, f, "2.0.0", "foo", report, "changed", true) + if changed.Load() != 1 { + t.Fatalf("actual CLI did not consume the mutated second %s response: %d", kind, changed.Load()) + } + }) + } + }) + t.Run("mutable-release", func(t *testing.T) { + f, report := fixture(t) + var changed atomic.Int32 + f.mutate = func(path string, _ int, out map[string]any) string { + if strings.Contains(path, "/releases/tags/") { + out["immutable"] = false + changed.Add(1) + } + return "" + } + reject(t, f, "2.0.0", "foo", report, "immutable", true) + if changed.Load() != 1 { + t.Fatal("actual CLI did not consume the mutable release") + } + }) + t.Run("invalid-target", func(t *testing.T) { + for _, tc := range []struct{ name, target string }{ + {"empty", ""}, {"latest", "latest"}, {"incomplete", "1.2"}, {"prerelease", "1.2.3-rc.1"}, + {"build-metadata", "1.2.3+build"}, {"leading-zero", "01.2.3"}, {"escape", "../1.2.3"}, + } { + t.Run(tc.name, func(t *testing.T) { + f, report := fixture(t) + reject(t, f, tc.target, "foo", report, "exact stable", false) + }) + } + }) + t.Run("path", func(t *testing.T) { + for _, kind := range []string{"plugin-escape", "plugin-symlink", "report-escape", "report-manifest", "report-parent-symlink", "report-blocked-parent"} { + t.Run(kind, func(t *testing.T) { + f, report := fixture(t) + plugin, want := "foo", "" + var watched []string + switch kind { + case "plugin-escape": + plugin, want = "../foo", "--plugin must name one plugin directory" + case "plugin-symlink": + outside := t.TempDir() + manifest := filepath.Join(outside, "manifest.json") + mustWrite(t, manifest, "outside manifest sentinel\n") + if err := os.Symlink(outside, filepath.Join(f.root, "plugins", "escape")); err != nil { + t.Fatal(err) + } + watched = append(watched, manifest) + plugin, want = "escape", "registry plugin path contains symlink" + case "report-escape": + report, want = "../escaped-operation.json", "invalid plugin or report path" + case "report-manifest": + report, want = f.manifest, "report path overlaps governed registry paths" + case "report-parent-symlink": + outside := t.TempDir() + sentinel := filepath.Join(outside, "sentinel") + mustWrite(t, sentinel, "outside report sentinel\n") + if err := os.Symlink(outside, filepath.Join(f.root, "linked")); err != nil { + t.Fatal(err) + } + watched = append(watched, sentinel) + report, want = filepath.Join(f.root, "linked", "operation.json"), "report path contains a symlink" + case "report-blocked-parent": + mustWrite(t, filepath.Join(f.root, "blocked"), "file, not directory\n") + report, want = filepath.Join(f.root, "blocked", "operation.json"), "not a directory" + } + reject(t, f, "2.0.0", plugin, report, want, false, watched...) + }) + } + }) + t.Run("extra-generated-path", func(t *testing.T) { + for _, path := range []string{"README.md", "v1/evil.json"} { + t.Run(path, func(t *testing.T) { + f, report := fixture(t) + mustWrite(t, filepath.Join(f.root, path), "unexpected\n") + reject(t, f, "2.0.0", "foo", report, "extra generated or modified registry path: "+path, false) + }) + } + }) + t.Run("invalid-report", func(t *testing.T) { + for _, kind := range []string{"unknown", "nested-unknown", "schema", "number", "escape", "extra-path", "missing", "noncanonical"} { + t.Run(kind, func(t *testing.T) { + f, reportPath := fixture(t) + seedReport(t, f, reportPath) + report, data := readRegistryReport(t, reportPath) + want := "invalid registry-sync-operation.v1 report bindings" + switch kind { + case "unknown": + report["unknown"] = true + want = `json: unknown field "unknown"` + case "nested-unknown": + report["release"].(map[string]any)["unknown"] = true + want = `json: unknown field "unknown"` + case "schema": + report["schema"] = "registry-sync-operation.v99" + case "number": + report["release"].(map[string]any)["id"] = 100 + want = "cannot unmarshal number" + case "escape": + report["changed_paths"] = []any{"../escape"} + case "extra-path": + report["changed_paths"] = []any{"README.md"} + case "missing": + delete(report, "source_sha") + case "noncanonical": + want = "report must contain exact number-free canonical JSON" + } + if kind == "noncanonical" { + data = append(data, '\n') + } else { + var err error + data, err = json.Marshal(report) + if err != nil { + t.Fatal(err) + } + } + mustWrite(t, reportPath, string(data)) + reject(t, f, "1.0.0", "foo", reportPath, want, false) + }) + } + }) +} diff --git a/cmd/wfctl/plugin_registry_sync_host_test.go b/cmd/wfctl/plugin_registry_sync_host_test.go new file mode 100644 index 00000000..40d30a0e --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_host_test.go @@ -0,0 +1,178 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func registrySyncRequestedHostBinary(host, requested string, provided bool) (string, error) { + if !provided { + return "", nil + } + if host != "linux" { + return "", fmt.Errorf("WFCTL_REGISTRY_SYNC_HOST_BINARY proof requires Linux: Go 1.26.5 does not use SSL_CERT_FILE on %s", host) + } + if requested == "" { + return "", fmt.Errorf("WFCTL_REGISTRY_SYNC_HOST_BINARY must name an executable file") + } + path, err := filepath.Abs(requested) + if err != nil { + return "", fmt.Errorf("WFCTL_REGISTRY_SYNC_HOST_BINARY: %w", err) + } + info, err := os.Lstat(path) + if err != nil { + return "", fmt.Errorf("WFCTL_REGISTRY_SYNC_HOST_BINARY: %w", err) + } + if !info.Mode().IsRegular() || info.Mode().Perm()&0111 == 0 { + return "", fmt.Errorf("WFCTL_REGISTRY_SYNC_HOST_BINARY must be a regular executable file, not a directory or symlink") + } + return path, nil +} + +func registrySyncActualCLIBinary(t *testing.T, host, apiURL string) string { + t.Helper() + requested, provided := os.LookupEnv("WFCTL_REGISTRY_SYNC_HOST_BINARY") + binary, err := registrySyncRequestedHostBinary(host, requested, provided) + if err != nil { + t.Fatal(err) + } + if provided { + return binary + } + binary = filepath.Join(t.TempDir(), "wfctl") + buildCtx, cancel := context.WithTimeout(t.Context(), 90*time.Second) + defer cancel() + build := exec.CommandContext(buildCtx, "go", registrySyncHostBuildArguments(host, binary, apiURL)...) + build.Env = registrySyncHostEnvironment(os.Environ()) + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build actual CLI: %v\n%s", err, out) + } + return binary +} + +func registrySyncHostBuildArguments(host, binary, apiURL string) []string { + args := []string{"build", "-p=2", "-o", binary} + if host != "linux" { + args = append(args, "-ldflags", "-s -w -X main.gitHubAPIBaseURL="+apiURL) + } + return append(args, ".") +} + +func registrySyncHostEnvironment(source []string) []string { + env := make([]string, 0, len(source)+2) + for _, variable := range source { + name, _, _ := strings.Cut(variable, "=") + switch strings.ToUpper(name) { + case "RELEASES_TOKEN", "GH_TOKEN", "GITHUB_TOKEN", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "GOWORK", "GOTOOLCHAIN": + continue + } + env = append(env, variable) + } + return append(env, "GOWORK=off", "GOTOOLCHAIN=go1.26.5") +} + +func TestPluginRegistrySyncHostBinary_Selection(t *testing.T) { + root := t.TempDir() + binary := filepath.Join(root, "wfctl") + if err := os.WriteFile(binary, []byte("selector fixture only"), 0700); err != nil { + t.Fatal(err) + } + nonExecutable := filepath.Join(root, "non-executable") + if err := os.WriteFile(nonExecutable, []byte("selector fixture only"), 0600); err != nil { + t.Fatal(err) + } + symlink := filepath.Join(root, "symlink") + if err := os.Symlink(binary, symlink); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name, host, path string + provided, valid bool + }{ + {"explicit", "linux", binary, true, true}, + {"absent", "linux", "", false, true}, + {"empty", "linux", "", true, false}, + {"missing", "linux", filepath.Join(root, "missing"), true, false}, + {"directory", "linux", root, true, false}, + {"non-executable", "linux", nonExecutable, true, false}, + {"symlink", "linux", symlink, true, false}, + {"darwin", "darwin", binary, true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := registrySyncRequestedHostBinary(tc.host, tc.path, tc.provided) + if tc.valid { + if err != nil || got != tc.path { + t.Fatalf("selection = %q, %v", got, err) + } + } else if err == nil || !strings.Contains(err.Error(), "WFCTL_REGISTRY_SYNC_HOST_BINARY") { + t.Fatalf("invalid explicit selection was accepted: %q, %v", got, err) + } + }) + } +} + +func TestPluginRegistrySyncHostBinary_BuildArguments(t *testing.T) { + for _, host := range []string{"linux", "darwin"} { + args := strings.Join(registrySyncHostBuildArguments(host, "/fixture/wfctl", "http://fixture.invalid"), " ") + modified := strings.Contains(args, "-ldflags") || strings.Contains(args, "gitHubAPIBaseURL") + if modified != (host == "darwin") { + t.Fatalf("%s build unexpectedly modifies the default API: %s", host, args) + } + } +} + +func TestPluginRegistrySyncHostBinary_CredentialFreeEnvironment(t *testing.T) { + env := registrySyncHostEnvironment([]string{ + "PATH=/fixture", "HOME=/fixture/home", "RELEASES_TOKEN=secret", "GH_TOKEN=secret", "GITHUB_TOKEN=secret", + "HTTP_PROXY=http://user:secret@outside.invalid", "https_proxy=http://outside.invalid", "ALL_PROXY=http://outside.invalid", "no_proxy=*", + "SSL_CERT_FILE=/outside/ca", "SSL_CERT_DIR=/outside/roots", "GOWORK=/outside/go.work", "GOTOOLCHAIN=go1.27.1", + }) + if got := strings.Join(env, "\n"); got != "PATH=/fixture\nHOME=/fixture/home\nGOWORK=off\nGOTOOLCHAIN=go1.26.5" { + t.Fatalf("host environment retained credentials or transport overrides:\n%s", got) + } +} + +func TestPluginRegistrySyncHostBinary_ExplicitNeverBuilds(t *testing.T) { + root := t.TempDir() + goTrap := filepath.Join(root, "go") + if err := os.WriteFile(goTrap, []byte("#!/bin/sh\nprintf 'unexpected source-build fallback\\n' >&2\nexit 99\n"), 0700); err != nil { + t.Fatal(err) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + cmd := exec.CommandContext(t.Context(), self, "-test.run=^TestPluginRegistrySyncTarget_ActualCLI$", "-test.v") + cmd.Env = append(os.Environ(), + "WFCTL_REGISTRY_SYNC_HOST_BINARY="+filepath.Join(root, "missing-wfctl"), + "PATH="+root+string(os.PathListSeparator)+os.Getenv("PATH"), + "GOWORK=off", "GOTOOLCHAIN=go1.26.5") + out, err := cmd.CombinedOutput() + if err == nil || !strings.Contains(string(out), "WFCTL_REGISTRY_SYNC_HOST_BINARY") || strings.Contains(string(out), "unexpected source-build fallback") { + t.Fatalf("explicit binary selection did not fail closed before building: %v\n%s", err, out) + } +} + +func TestPluginRegistrySyncHostBinary_ExplicitSelection(t *testing.T) { + root := t.TempDir() + goTrap := filepath.Join(root, "go") + if err := os.WriteFile(goTrap, []byte("#!/bin/sh\nprintf 'unexpected source-build fallback\\n' >&2\nexit 99\n"), 0700); err != nil { + t.Fatal(err) + } + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv("WFCTL_REGISTRY_SYNC_HOST_BINARY", self) + t.Setenv("PATH", root+string(os.PathListSeparator)+os.Getenv("PATH")) + // This exercises selection only, not Linux execution or release proof. + if got := registrySyncActualCLIBinary(t, "linux", "http://fixture.invalid"); got != self { + t.Fatalf("provided executable was not selected unchanged: %q", got) + } +} diff --git a/cmd/wfctl/plugin_registry_sync_proxy_test.go b/cmd/wfctl/plugin_registry_sync_proxy_test.go new file mode 100644 index 00000000..c84c913f --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_proxy_test.go @@ -0,0 +1,250 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +type registrySyncReleaseProxy struct { + url, caFile, caDirectory string + roots *x509.CertPool + connects, requests atomic.Int32 +} + +func newRegistrySyncReleaseProxy(t *testing.T, handler http.Handler) *registrySyncReleaseProxy { + t.Helper() + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ca := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "registry-sync fixture CA"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(time.Hour), + IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign, + } + caDER, err := x509.CreateCertificate(rand.Reader, ca, ca, &caKey.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + ca, err = x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + leaf := &x509.Certificate{ + SerialNumber: big.NewInt(2), DNSNames: []string{"api.github.com"}, + NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leaf, ca, &key.PublicKey, caKey) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + p := ®istrySyncReleaseProxy{ + caFile: filepath.Join(root, "ca.pem"), caDirectory: filepath.Join(root, "empty-roots"), roots: x509.NewCertPool(), + } + p.roots.AddCert(ca) + if err := os.WriteFile(p.caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}), 0600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(p.caDirectory, 0700); err != nil { + t.Fatal(err) + } + backend := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Host != "api.github.com" || r.Header.Get("Authorization") != "" || r.Header.Get("Proxy-Authorization") != "" { + http.Error(w, "credential-free api.github.com requests only", http.StatusForbidden) + return + } + p.requests.Add(1) + handler.ServeHTTP(w, r) + })) + backend.TLS = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{{Certificate: [][]byte{leafDER, caDER}, PrivateKey: key}}, + } + backend.StartTLS() + var mu sync.Mutex + connections := make(map[net.Conn]struct{}) + closing := false + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodConnect || r.Host != "api.github.com:443" || r.RequestURI != "api.github.com:443" || r.Header.Get("Proxy-Authorization") != "" { + http.Error(w, "CONNECT api.github.com:443 only", http.StatusForbidden) + return + } + upstream, err := (&net.Dialer{Timeout: 3 * time.Second}).DialContext(r.Context(), "tcp", backend.Listener.Addr().String()) + if err != nil { + http.Error(w, "fixture unavailable", http.StatusBadGateway) + return + } + defer upstream.Close() + downstream, buffered, err := w.(http.Hijacker).Hijack() + if err != nil { + return + } + defer downstream.Close() + mu.Lock() + if closing { + mu.Unlock() + return + } + connections[upstream], connections[downstream] = struct{}{}, struct{}{} + mu.Unlock() + defer func() { + mu.Lock() + delete(connections, upstream) + delete(connections, downstream) + mu.Unlock() + }() + if _, err := fmt.Fprint(downstream, "HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil { + return + } + p.connects.Add(1) + done := make(chan struct{}) + go func() { + _, _ = io.Copy(upstream, buffered.Reader) + _ = upstream.Close() + close(done) + }() + _, _ = io.Copy(downstream, upstream) + _ = downstream.Close() + _ = upstream.Close() + <-done + })) + p.url = proxy.URL + t.Cleanup(func() { + mu.Lock() + closing = true + for connection := range connections { + _ = connection.Close() + } + mu.Unlock() + proxy.Close() + backend.Close() + }) + return p +} + +func (p *registrySyncReleaseProxy) client(t *testing.T) *http.Client { + t.Helper() + proxyURL, err := url.Parse(p.url) + if err != nil { + t.Fatal(err) + } + transport := &http.Transport{ + Proxy: func(r *http.Request) (*url.URL, error) { + if r.URL.Scheme == "https" { + return proxyURL, nil + } + return nil, nil + }, + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12, RootCAs: p.roots}, + } + t.Cleanup(transport.CloseIdleConnections) + return &http.Client{Transport: transport, Timeout: 3 * time.Second} +} + +func TestPluginRegistrySyncReleaseProxy_DefaultAPI(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + proxy := newRegistrySyncReleaseProxy(t, f.handler) + client := proxy.client(t) + resp, err := client.Get("https://api.github.com/repos/owner/repo/releases/tags/v2.0.0") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var release struct { + Tag string `json:"tag_name"` + Assets []struct { + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK || release.Tag != "v2.0.0" || len(release.Assets) != 2 || !strings.HasPrefix(release.Assets[0].URL, f.apiURL+"/checksums/") { + t.Fatalf("proxy did not use the existing structured fixture: %#v status=%d", release, resp.StatusCode) + } + checksums, err := client.Get(release.Assets[0].URL) + if err != nil { + t.Fatal(err) + } + defer checksums.Body.Close() + body, err := io.ReadAll(checksums.Body) + if err != nil || !strings.Contains(string(body), "workflow-plugin-foo-linux-amd64.tar.gz") { + t.Fatalf("loopback checksum fixture = %q, %v", body, err) + } + if proxy.connects.Load() != 1 || proxy.requests.Load() != 1 { + t.Fatalf("default GitHub API was not tunneled exactly once: CONNECT=%d API=%d", proxy.connects.Load(), proxy.requests.Load()) + } +} + +func TestPluginRegistrySyncReleaseProxy_DeniesOtherHosts(t *testing.T) { + proxy := newRegistrySyncReleaseProxy(t, http.NotFoundHandler()) + client := proxy.client(t) + for _, host := range []string{"github.com", "api.github.com.evil.invalid", "api.github.com:444", "127.0.0.1:443"} { + if resp, err := client.Get("https://" + host + "/"); err == nil { + _ = resp.Body.Close() + t.Fatalf("proxy allowed a foreign CONNECT destination: %s", host) + } + } + if proxy.connects.Load() != 0 { + t.Fatal("denied CONNECT reached the TLS backend") + } +} + +func TestPluginRegistrySyncReleaseProxy_DeniesCredentials(t *testing.T) { + proxy := newRegistrySyncReleaseProxy(t, http.NotFoundHandler()) + client := proxy.client(t) + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://api.github.com/", nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Authorization", "Bearer fixture-secret") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden || proxy.requests.Load() != 0 { + t.Fatal("credential-bearing API request reached the fixture handler") + } + connect, err := http.NewRequestWithContext(t.Context(), http.MethodConnect, proxy.url, nil) + if err != nil { + t.Fatal(err) + } + connect.Host = "api.github.com:443" + connect.URL.Opaque = "api.github.com:443" + connect.Header.Set("Proxy-Authorization", "Basic fixture-secret") + resp, err = http.DefaultClient.Do(connect) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusForbidden || proxy.connects.Load() != 1 { + t.Fatal("credential-bearing CONNECT reached the TLS backend") + } +} diff --git a/cmd/wfctl/plugin_registry_sync_report.go b/cmd/wfctl/plugin_registry_sync_report.go new file mode 100644 index 00000000..95a38041 --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_report.go @@ -0,0 +1,441 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const registrySyncOperationSchema = "registry-sync-operation.v1" + +type registrySyncOperation struct { + Schema string `json:"schema"` + Plugin string `json:"plugin"` + Repository string `json:"repository"` + Source string `json:"source"` + OldVersion string `json:"old_version"` + NewVersion string `json:"new_version"` + Tag string `json:"tag"` + TagObjects []registrySyncGitObject `json:"tag_objects"` + SourceSHA string `json:"source_sha"` + SourceDigest string `json:"source_digest"` + Release registrySyncReportRelease `json:"release"` + Assets []registrySyncReportAsset `json:"assets"` + Checksums []registrySyncReportChecksum `json:"checksums"` + ChecksumsDigest string `json:"checksums_digest"` + ChangedPaths []string `json:"changed_paths"` + BaseTree string `json:"base_tree"` + ResultTree string `json:"result_tree"` + DiffDigest string `json:"diff_digest"` +} + +type registrySyncReportRelease struct { + ID string `json:"id"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish"` + Immutable bool `json:"immutable"` +} + +type registrySyncReportAsset struct { + ID string `json:"id"` + Name string `json:"name"` + Size string `json:"size"` + URL string `json:"url"` + Digest string `json:"digest"` +} + +type registrySyncReportChecksum struct { + Name string `json:"name"` + SHA256 string `json:"sha256"` +} + +func encodeRegistrySyncOperation(operation registrySyncOperation) ([]byte, error) { + data, err := json.Marshal(operation) + if err != nil { + return nil, err + } + var value any + if err := registrySyncDecodeJSON(data, &value); err != nil { + return nil, err + } + return workflowCanonicalJSONV1(value) +} + +func decodeRegistrySyncOperation(data []byte) (registrySyncOperation, error) { + var operation registrySyncOperation + if len(data) > 1<<20 || !utf8.Valid(data) { + return operation, fmt.Errorf("invalid report size or UTF-8") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + decoder.UseNumber() + if err := decoder.Decode(&operation); err != nil { + return operation, fmt.Errorf("invalid registry operation report: %w", err) + } + if err := decoder.Decode(new(any)); err != io.EOF { + return operation, fmt.Errorf("report contains trailing data") + } + if err := validateRegistrySyncOperation(operation); err != nil { + return operation, err + } + canonical, err := encodeRegistrySyncOperation(operation) + if err != nil || !bytes.Equal(data, canonical) { + return operation, fmt.Errorf("report must contain exact number-free canonical JSON") + } + return operation, nil +} + +func validateRegistrySyncOperation(o registrySyncOperation) error { + invalid := func() error { return fmt.Errorf("invalid %s report bindings", registrySyncOperationSchema) } + if o.Schema != registrySyncOperationSchema || !validRegistryReportPlugin(o.Plugin) { + return invalid() + } + repo, err := registrySyncRepository(o.Source) + if err != nil || repo != o.Repository { + return invalid() + } + oldTag, oldErr := registrySyncStableTag(o.OldVersion) + newTag, newErr := registrySyncStableTag(o.NewVersion) + if oldErr != nil || newErr != nil || oldTag != "v"+o.OldVersion || newTag != "v"+o.NewVersion || o.Tag != newTag || o.Release.TagName != o.Tag || !o.Release.Immutable || o.Release.TargetCommitish == "" || !registryReportDecimal(o.Release.ID, false) { + return invalid() + } + if !registrySyncValidGitSHA(o.SourceSHA) || !registrySyncValidGitSHA(o.BaseTree) || !registrySyncValidGitSHA(o.ResultTree) || !registryReportDigest(o.SourceDigest) || !registryReportDigest(o.ChecksumsDigest) || !registryReportDigest(o.DiffDigest) || len(o.TagObjects) == 0 || len(o.TagObjects) > 9 { + return invalid() + } + for i, object := range o.TagObjects { + wantType := "tag" + if i == len(o.TagObjects)-1 { + wantType = "commit" + } + if object.Type != wantType || !registrySyncValidGitSHA(object.SHA) { + return invalid() + } + } + if o.TagObjects[len(o.TagObjects)-1].SHA != o.SourceSHA || len(o.Assets) == 0 || len(o.Checksums) == 0 || o.ChangedPaths == nil { + return invalid() + } + if len(o.ChangedPaths) > 1 || (len(o.ChangedPaths) == 1 && o.ChangedPaths[0] != "plugins/"+o.Plugin+"/manifest.json") { + return invalid() + } + if len(o.ChangedPaths) == 0 { + if o.BaseTree != o.ResultTree || o.DiffDigest != "sha256:"+registrySyncSHA256(nil) { + return invalid() + } + } else if o.BaseTree == o.ResultTree { + return invalid() + } + ids := map[string]bool{} + checksumAsset := false + for i, asset := range o.Assets { + parsedURL, err := url.Parse(asset.URL) + if !registryReportDecimal(asset.ID, false) || !registryReportDecimal(asset.Size, true) || ids[asset.ID] || !validRegistryReportPlugin(asset.Name) || (i > 0 && o.Assets[i-1].Name >= asset.Name) || err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") || (asset.Digest != "" && !registryReportDigest(asset.Digest)) { + return invalid() + } + ids[asset.ID] = true + checksumAsset = checksumAsset || asset.Name == "checksums.txt" + } + checksums := map[string]string{} + for i, checksum := range o.Checksums { + if !validRegistryReportPlugin(checksum.Name) || !registryReportDigest("sha256:"+checksum.SHA256) || (i > 0 && o.Checksums[i-1].Name >= checksum.Name) { + return invalid() + } + checksums[checksum.Name] = checksum.SHA256 + } + platforms := 0 + for _, asset := range o.Assets { + if _, _, ok := releaseAssetPlatform(asset.Name); ok { + if checksums[asset.Name] == "" || (asset.Digest != "" && asset.Digest != "sha256:"+checksums[asset.Name]) { + return invalid() + } + platforms++ + } + } + if !checksumAsset || platforms == 0 { + return invalid() + } + return nil +} + +func validRegistryReportPlugin(value string) bool { + return value != "" && value != "." && value != ".." && filepath.IsLocal(value) && !strings.ContainsAny(value, `/\\`) +} + +func registryReportDecimal(value string, allowZero bool) bool { + number, err := strconv.ParseUint(value, 10, 64) + return err == nil && (number != 0 || allowZero) && strconv.FormatUint(number, 10) == value +} + +func registryReportDigest(value string) bool { + if !strings.HasPrefix(value, "sha256:") { + return false + } + normalized, err := NormalizeSHA256Hex(strings.TrimPrefix(value, "sha256:")) + return err == nil && value == "sha256:"+normalized +} + +type registrySyncReportContext struct { + root string + path string + reportRel string + manifestRel string + temp string + index string + head string + baseTree string + previous []byte + mode os.FileMode +} + +func prepareRegistrySyncReport(registryDir, plugin, reportPath string) (*registrySyncReportContext, error) { + if !validRegistryReportPlugin(plugin) || (!filepath.IsAbs(reportPath) && !filepath.IsLocal(reportPath)) { + return nil, fmt.Errorf("invalid plugin or report path") + } + root, err := filepath.Abs(registryDir) + if err != nil { + return nil, err + } + originalRoot := root + root, err = filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + path, err := filepath.Abs(reportPath) + if err != nil { + return nil, err + } + if relative, err := filepath.Rel(originalRoot, path); err == nil && filepath.IsLocal(relative) { + path = filepath.Join(root, relative) + } else { + parent, tail := filepath.Dir(path), filepath.Base(path) + for { + if _, err := os.Lstat(parent); err == nil { + break + } else if !os.IsNotExist(err) { + return nil, err + } + tail = filepath.Join(filepath.Base(parent), tail) + parent = filepath.Dir(parent) + } + resolved, err := filepath.EvalSymlinks(parent) + if err != nil { + return nil, err + } + path = filepath.Join(resolved, tail) + } + c := ®istrySyncReportContext{root: root, path: path, manifestRel: "plugins/" + plugin + "/manifest.json", mode: 0o644} + if relative, err := filepath.Rel(root, path); err == nil && filepath.IsLocal(relative) { + c.reportRel = filepath.ToSlash(relative) + if c.reportRel == "." || c.reportRel == "README.md" || c.reportRel == ".git" || strings.HasPrefix(c.reportRel, ".git/") || strings.HasPrefix(c.reportRel, "plugins/") || strings.HasPrefix(c.reportRel, "v1/") { + return nil, fmt.Errorf("report path overlaps governed registry paths") + } + current := root + for component := range strings.SplitSeq(relative, string(filepath.Separator)) { + current = filepath.Join(current, component) + if info, err := os.Lstat(current); err == nil && info.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("report path contains a symlink") + } else if err != nil && !os.IsNotExist(err) { + return nil, err + } + } + } + if info, err := os.Lstat(path); err == nil { + if !info.Mode().IsRegular() || info.Size() > 1<<20 { + return nil, fmt.Errorf("report must be a regular file of at most 1 MiB") + } + c.mode = info.Mode().Perm() + c.previous, err = os.ReadFile(path) + if err != nil { + return nil, err + } + if _, err := decodeRegistrySyncOperation(c.previous); err != nil { + return nil, err + } + } else if !os.IsNotExist(err) { + return nil, err + } + top, err := registrySyncGit(root, "", nil, "rev-parse", "--show-toplevel") + if err != nil || strings.TrimSpace(string(top)) != root { + return nil, fmt.Errorf("registry-dir must be a committed Git checkout root: %v", err) + } + head, err := registrySyncGit(root, "", nil, "rev-parse", "HEAD") + if err != nil { + return nil, err + } + c.head = strings.TrimSpace(string(head)) + if _, err := registrySyncGit(root, "", nil, "cat-file", "-e", "HEAD:"+c.manifestRel); err != nil { + return nil, fmt.Errorf("plugin manifest must exist in the committed registry: %w", err) + } + if err := c.checkPaths(); err != nil { + return nil, err + } + c.temp, err = os.MkdirTemp("", "wfctl-registry-report-*") + if err != nil { + return nil, err + } + c.index = filepath.Join(c.temp, "index") + c.baseTree, err = c.snapshotTree() + if err != nil { + c.close() + return nil, err + } + return c, nil +} + +func (c *registrySyncReportContext) close() { + _ = os.RemoveAll(c.temp) +} + +func registrySyncGit(root, index string, input []byte, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "git", append([]string{"-C", root, "--literal-pathspecs"}, args...)...) // #nosec G204 -- internal Git operations use validated registry paths, never shell commands. + cmd.Env = slices.DeleteFunc(os.Environ(), func(entry string) bool { + name, _, _ := strings.Cut(entry, "=") + return name == "GIT_INDEX_FILE" || name == "GIT_DIR" || name == "GIT_WORK_TREE" + }) + cmd.Env = append(cmd.Env, "GIT_OPTIONAL_LOCKS=0") + if index != "" { + cmd.Env = append(cmd.Env, "GIT_INDEX_FILE="+index) + } + cmd.Stdin = bytes.NewReader(input) + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("registry Git %s: %w: %s", args[0], err, strings.TrimSpace(stderr.String())) + } + return out, nil +} + +func (c *registrySyncReportContext) checkPaths() error { + for _, args := range [][]string{ + {"diff", "--name-only", "-z", "--no-ext-diff", "HEAD", "--"}, + {"ls-files", "--others", "--exclude-standard", "-z"}, + } { + out, err := registrySyncGit(c.root, "", nil, args...) + if err != nil { + return err + } + for path := range strings.SplitSeq(string(out), "\x00") { + if path != "" && path != c.manifestRel && path != c.reportRel { + return fmt.Errorf("extra generated or modified registry path: %s", path) + } + } + } + return nil +} + +func (c *registrySyncReportContext) snapshotTree() (string, error) { + if _, err := registrySyncGit(c.root, c.index, nil, "read-tree", "HEAD"); err != nil { + return "", err + } + if c.reportRel != "" { + if _, err := registrySyncGit(c.root, c.index, nil, "update-index", "--force-remove", "--", c.reportRel); err != nil { + return "", err + } + } + if _, err := registrySyncGit(c.root, c.index, nil, "add", "-u", "--", "."); err != nil { + return "", err + } + tree, err := registrySyncGit(c.root, c.index, nil, "write-tree") + return strings.TrimSpace(string(tree)), err +} + +func (c *registrySyncReportContext) write(plugin, source, repo, oldVersion, newVersion string, snapshot *registrySyncReleaseSnapshot, manifest []byte) error { + if err := c.checkPaths(); err != nil { + return err + } + head, err := registrySyncGit(c.root, "", nil, "rev-parse", "HEAD") + if err != nil || strings.TrimSpace(string(head)) != c.head { + return fmt.Errorf("registry HEAD changed during sync") + } + currentTree, err := c.snapshotTree() + if err != nil { + return err + } + if currentTree != c.baseTree { + return fmt.Errorf("registry tree changed during sync") + } + mode, err := registrySyncGit(c.root, c.index, nil, "ls-files", "--stage", "--", c.manifestRel) + if err != nil { + return err + } + gitMode, _, ok := strings.Cut(string(mode), " ") + if !ok || (gitMode != "100644" && gitMode != "100755") { + return fmt.Errorf("registry manifest must be a tracked regular file") + } + blob, err := registrySyncGit(c.root, c.index, manifest, "hash-object", "-w", "--path="+c.manifestRel, "--stdin") + if err != nil { + return err + } + if _, err := registrySyncGit(c.root, c.index, nil, "update-index", "--cacheinfo", gitMode+","+strings.TrimSpace(string(blob))+","+c.manifestRel); err != nil { + return err + } + result, err := registrySyncGit(c.root, c.index, nil, "write-tree") + if err != nil { + return err + } + resultTree := strings.TrimSpace(string(result)) + pathsBytes, err := registrySyncGit(c.root, c.index, nil, "diff-tree", "--no-commit-id", "-r", "--name-only", "-z", "--no-renames", c.baseTree, resultTree, "--") + if err != nil { + return err + } + paths := []string{} + for path := range strings.SplitSeq(string(pathsBytes), "\x00") { + if path != "" { + paths = append(paths, path) + } + } + diff, err := registrySyncGit(c.root, c.index, nil, "diff-tree", "--no-commit-id", "-r", "-p", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "--src-prefix=a/", "--dst-prefix=b/", c.baseTree, resultTree, "--") + if err != nil { + return err + } + operation := registrySyncOperation{ + Schema: registrySyncOperationSchema, Plugin: plugin, Repository: repo, Source: source, + OldVersion: oldVersion, NewVersion: newVersion, Tag: snapshot.Release.TagName, + TagObjects: snapshot.TagObjects, SourceSHA: snapshot.SourceSHA, SourceDigest: "sha256:" + registrySyncSHA256(snapshot.PluginJSON), + Release: registrySyncReportRelease{ID: strconv.FormatInt(snapshot.Release.ID, 10), TagName: snapshot.Release.TagName, TargetCommitish: snapshot.Release.TargetCommitish, Immutable: snapshot.Release.Immutable}, + Assets: []registrySyncReportAsset{}, Checksums: []registrySyncReportChecksum{}, ChecksumsDigest: "sha256:" + snapshot.ChecksumsSHA256, + ChangedPaths: paths, BaseTree: c.baseTree, ResultTree: resultTree, DiffDigest: "sha256:" + registrySyncSHA256(diff), + } + for _, asset := range snapshot.Release.Assets { + operation.Assets = append(operation.Assets, registrySyncReportAsset{ID: strconv.FormatInt(asset.ID, 10), Name: asset.Name, Size: strconv.FormatInt(asset.Size, 10), URL: asset.BrowserDownloadURL, Digest: asset.Digest}) + } + names := make([]string, 0, len(snapshot.Checksums)) + for name := range snapshot.Checksums { + names = append(names, name) + } + slices.Sort(names) + for _, name := range names { + operation.Checksums = append(operation.Checksums, registrySyncReportChecksum{Name: name, SHA256: snapshot.Checksums[name]}) + } + if err := validateRegistrySyncOperation(operation); err != nil { + return err + } + data, err := encodeRegistrySyncOperation(operation) + if err != nil { + return err + } + current, err := os.ReadFile(c.path) + if err != nil && !os.IsNotExist(err) { + return err + } + if !bytes.Equal(current, c.previous) { + return fmt.Errorf("operation report changed during sync") + } + if bytes.Equal(current, data) { + return nil + } + return atomicWriteFile(c.path, data, c.mode) +} diff --git a/cmd/wfctl/plugin_registry_sync_report_test.go b/cmd/wfctl/plugin_registry_sync_report_test.go new file mode 100644 index 00000000..fc1949bd --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_report_test.go @@ -0,0 +1,497 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +func registryReportGit(t *testing.T, root string, args ...string) []byte { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", append([]string{"-C", root}, args...)...) + cmd.Env = append(os.Environ(), "GIT_CONFIG_GLOBAL="+os.DevNull, "GIT_CONFIG_NOSYSTEM=1") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return out +} + +func commitRegistryReportFixture(t *testing.T, f *registrySyncTargetFixture) { + t.Helper() + registryReportGit(t, f.root, "init", "-q") + mustWrite(t, filepath.Join(f.root, "README.md"), "Fixture registry\n") + registryReportGit(t, f.root, "add", "--", "plugins", "README.md") + registryReportGit(t, f.root, "-c", "user.name=Registry Fixture", "-c", "user.email=registry@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath="+os.DevNull, "commit", "-q", "-m", "fixture") +} + +func readRegistryReport(t *testing.T, path string) (map[string]any, []byte) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var report map[string]any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&report); err != nil { + t.Fatal(err) + } + canonical, err := workflowCanonicalJSONV1(report) + if err != nil || !bytes.Equal(canonical, data) { + t.Fatalf("report is not number-free canonical JSON: %q, %v", data, err) + } + return report, data +} + +func registryReportFixtureTree(t *testing.T, f *registrySyncTargetFixture, reportPath string) string { + t.Helper() + index := filepath.Join(t.TempDir(), "index") + git := func(args ...string) []byte { + t.Helper() + cmd := exec.CommandContext(t.Context(), "git", append([]string{"-C", f.root, "--literal-pathspecs"}, args...)...) + cmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+index) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("fixture tree git %v: %v\n%s", args, err, out) + } + return out + } + git("read-tree", "HEAD") + if relative, err := filepath.Rel(f.root, reportPath); err == nil && filepath.IsLocal(relative) { + git("rm", "--cached", "--ignore-unmatch", "--", filepath.ToSlash(relative)) + } + git("add", "-u", "--", ".") + return strings.TrimSpace(string(git("write-tree"))) +} + +func assertRegistryReportBinding(t *testing.T, f *registrySyncTargetFixture, path, baseTree, oldVersion, newVersion string, changed bool) []byte { + t.Helper() + report, data := readRegistryReport(t, path) + result := registryReportFixtureTree(t, f, path) + if report["schema"] != "registry-sync-operation.v1" || report["plugin"] != "foo" || report["repository"] != "owner/repo" || report["old_version"] != oldVersion || report["new_version"] != newVersion || report["base_tree"] != baseTree || report["result_tree"] != result { + t.Fatalf("report bindings disagree with actual fixture: %s", data) + } + diff := registryReportGit(t, f.root, "diff-tree", "--no-commit-id", "-r", "-p", "--binary", "--full-index", "--no-renames", "--no-ext-diff", "--no-textconv", "--no-color", "--src-prefix=a/", "--dst-prefix=b/", baseTree, result, "--") + if report["diff_digest"] != "sha256:"+registrySyncSHA256(diff) { + t.Fatalf("report diff digest does not bind Git diff bytes: %s", data) + } + paths := report["changed_paths"].([]any) + if changed { + if len(paths) != 1 || paths[0] != "plugins/foo/manifest.json" || baseTree == result { + t.Fatalf("wrong changed-path set: %s", data) + } + } else if len(paths) != 0 || baseTree != result || len(diff) != 0 { + t.Fatalf("no-op must bind equal trees and the empty diff: %s", data) + } + return data +} + +func TestPluginRegistrySyncReport_ForwardNoopRollback(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + path := filepath.Join(f.root, ".github", "registry-operations", "operation.json") + indexBefore := registryReportGit(t, f.root, "ls-files", "--stage", "-z") + headBefore := registryReportGit(t, f.root, "rev-parse", "HEAD") + old := "1.0.0" + var priorNoop []byte + for _, target := range []string{"2.0.0", "2.0.0", "2.0.0", "1.0.0", "1.0.0", "1.0.0"} { + base := registryReportFixtureTree(t, f, path) + if err := f.run(target, "--report", path); err != nil { + t.Fatalf("report target %s: %v", target, err) + } + changed := old != target + data := assertRegistryReportBinding(t, f, path, base, old, target, changed) + if !changed { + if priorNoop != nil && !bytes.Equal(priorNoop, data) { + t.Fatal("repeated no-op report changed bytes") + } + priorNoop = data + } else { + priorNoop = nil + } + old = target + } + if !bytes.Equal(indexBefore, registryReportGit(t, f.root, "ls-files", "--stage", "-z")) || !bytes.Equal(headBefore, registryReportGit(t, f.root, "rev-parse", "HEAD")) { + t.Fatal("report generation changed the checkout index or HEAD") + } +} + +func TestPluginRegistrySyncReport_CRLFNoop(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + registryReportGit(t, f.root, "init", "-q") + registryReportGit(t, f.root, "config", "core.autocrlf", "false") + if err := f.run("2.0.0"); err != nil { + t.Fatal(err) + } + commitRegistryReportFixture(t, f) + lf := registryReportGit(t, f.root, "show", "HEAD:plugins/foo/manifest.json") + if !bytes.Contains(lf, []byte("\n")) || bytes.Contains(lf, []byte("\r")) { + t.Fatal("fixture did not commit an LF manifest") + } + registryReportGit(t, f.root, "config", "core.autocrlf", "true") + crlf := bytes.ReplaceAll(lf, []byte("\n"), []byte("\r\n")) + mustWrite(t, f.manifest, string(crlf)) + info, err := os.Stat(f.manifest) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(f.root, "operation.json") + base := registryReportFixtureTree(t, f, path) + if base != strings.TrimSpace(string(registryReportGit(t, f.root, "rev-parse", "HEAD^{tree}"))) { + t.Fatal("CRLF fixture was not unchanged under Git's EOL rules") + } + var prior []byte + for range 2 { + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + data := assertRegistryReportBinding(t, f, path, base, "2.0.0", "2.0.0", false) + if prior != nil && !bytes.Equal(prior, data) { + t.Fatal("repeated CRLF no-op changed report bytes") + } + prior = data + after, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(f.manifest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(crlf, after) || !os.SameFile(info, afterInfo) { + t.Fatal("CRLF no-op rewrote the working manifest") + } + } +} + +func TestPluginRegistrySyncReport_CleanFilterResultTree(t *testing.T) { + if _, err := exec.LookPath("sed"); err != nil { + t.Skip("Git clean-filter fixture requires sed") + } + f := newRegistrySyncTargetFixture(t) + registryReportGit(t, f.root, "init", "-q") + registryReportGit(t, f.root, "config", "core.autocrlf", "false") + registryReportGit(t, f.root, "config", "filter.registry-report.clean", "sed 's/preserved/filtered/g'") + registryReportGit(t, f.root, "config", "filter.registry-report.required", "true") + mustWrite(t, filepath.Join(f.root, ".gitattributes"), "plugins/foo/manifest.json filter=registry-report\n") + registryReportGit(t, f.root, "add", "--", ".gitattributes") + commitRegistryReportFixture(t, f) + committed := registryReportGit(t, f.root, "show", "HEAD:plugins/foo/manifest.json") + if !bytes.Contains(committed, []byte(`"description":"filtered"`)) { + t.Fatal("fixture did not apply its clean filter to the committed manifest") + } + indexBefore := registryReportGit(t, f.root, "ls-files", "--stage", "-z") + headBefore := registryReportGit(t, f.root, "rev-parse", "HEAD") + path := filepath.Join(f.root, "operation.json") + old := "1.0.0" + for _, target := range []string{"2.0.0", "2.0.0", "1.0.0"} { + base := registryReportFixtureTree(t, f, path) + if err := f.run(target, "--report", path); err != nil { + t.Fatal(err) + } + assertRegistryReportBinding(t, f, path, base, old, target, old != target) + report, _ := readRegistryReport(t, path) + result := registryReportGit(t, f.root, "show", report["result_tree"].(string)+":plugins/foo/manifest.json") + working, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(working, []byte("preserved")) || !bytes.Equal(result, bytes.ReplaceAll(working, []byte("preserved"), []byte("filtered"))) { + t.Fatal("report result did not bind the locally clean-filtered manifest") + } + old = target + } + if !bytes.Equal(indexBefore, registryReportGit(t, f.root, "ls-files", "--stage", "-z")) || !bytes.Equal(headBefore, registryReportGit(t, f.root, "rev-parse", "HEAD")) { + t.Fatal("filtered report generation changed the checkout index or HEAD") + } +} + +func TestPluginRegistrySyncReport_AutomaticSelectionExactIDs(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + f.mutate = func(path string, _ int, out map[string]any) string { + if strings.Contains(path, "/releases/tags/") { + out["id"] = int64(9007199254740993) + out["target_commitish"] = "<>&" + asset := out["assets"].([]any)[1].(map[string]any) + asset["id"], asset["size"] = int64(9007199254740995), int64(9007199254740997) + } + return "" + } + path := filepath.Join(t.TempDir(), "operation.json") + if err := runPluginRegistrySync([]string{"--registry-dir", f.root, "--plugin", "foo", "--fix", "--report", path}); err != nil { + t.Fatal(err) + } + report, data := readRegistryReport(t, path) + release := report["release"].(map[string]any) + if release["id"] != "9007199254740993" || !bytes.Contains(data, []byte(`"target_commitish":"<>&"`)) { + t.Fatalf("IDs rounded or strings HTML-escaped: %s", data) + } + asset := report["assets"].([]any)[1].(map[string]any) + if asset["id"] != "9007199254740995" || asset["size"] != "9007199254740997" || f.latest.Load() != 1 { + t.Fatalf("asset ID/size or automatic selection incorrect: %s", data) + } +} + +func TestPluginRegistrySyncReport_RejectsExistingInvalidReport(t *testing.T) { + for _, kind := range []string{"unknown", "nested unknown", "schema", "number", "escape", "extra path", "missing", "noncanonical"} { + t.Run(kind, func(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + path := filepath.Join(f.root, "operation.json") + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + report, data := readRegistryReport(t, path) + switch kind { + case "unknown": + report["unknown"] = true + case "nested unknown": + report["release"].(map[string]any)["unknown"] = true + case "schema": + report["schema"] = "registry-sync-operation.v99" + case "number": + report["release"].(map[string]any)["id"] = 100 + case "escape": + report["changed_paths"] = []any{"../escape"} + case "extra path": + report["changed_paths"] = []any{"README.md"} + case "missing": + delete(report, "source_sha") + } + if kind == "noncanonical" { + data = append(data, '\n') + } else { + var err error + data, err = json.Marshal(report) + if err != nil { + t.Fatal(err) + } + } + mustWrite(t, path, string(data)) + before, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if err := f.run("1.0.0", "--report", path); err == nil { + t.Fatal("accepted invalid existing report") + } + after, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + unchanged, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !bytes.Equal(data, unchanged) { + t.Fatal("failed report validation changed manifest or report") + } + }) + } +} + +func TestPluginRegistrySyncReport_RejectsExtraGeneratedPath(t *testing.T) { + for _, path := range []string{"README.md", "v1/evil.json"} { + t.Run(path, func(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + mustWrite(t, filepath.Join(f.root, path), "unexpected\n") + if err := f.run("2.0.0", "--report", filepath.Join(t.TempDir(), "operation.json")); err == nil { + t.Fatal("accepted extra generated path") + } + }) + } +} + +func TestPluginRegistrySyncTarget_RejectsMutableRelease(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + f.mutate = func(path string, _ int, out map[string]any) string { + if strings.Contains(path, "/releases/tags/") { + out["immutable"] = false + } + return "" + } + if err := f.run("2.0.0"); err == nil || !strings.Contains(err.Error(), "immutable") { + t.Fatalf("expected mutable release rejection, got %v", err) + } +} + +func TestPluginRegistrySyncReport_Schema(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + path := filepath.Join(t.TempDir(), "operation.json") + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + report, _ := readRegistryReport(t, path) + data, err := os.ReadFile("../../docs/schemas/registry-sync-operation-v1.schema.json") + if err != nil { + t.Fatal(err) + } + var doc any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatal(err) + } + compiler := jsonschema.NewCompiler() + const schemaURL = "https://gocodealone.com/schemas/registry-sync-operation-v1.schema.json" + if err := compiler.AddResource(schemaURL, doc); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile(schemaURL) + if err != nil { + t.Fatal(err) + } + if err := schema.Validate(report); err != nil { + t.Fatal(err) + } + report["release"].(map[string]any)["unexpected"] = true + if err := schema.Validate(report); err == nil { + t.Fatal("schema accepted an unknown nested field") + } +} + +func TestPluginRegistrySyncReport_TrackedReportExcluded(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + path := filepath.Join(f.root, "operation.json") + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + registryReportGit(t, f.root, "add", "--", "plugins/foo/manifest.json", "operation.json") + registryReportGit(t, f.root, "-c", "user.name=Registry Fixture", "-c", "user.email=registry@example.invalid", "-c", "commit.gpgsign=false", "-c", "core.hooksPath="+os.DevNull, "commit", "-q", "-m", "tracked operation fixture") + base := registryReportFixtureTree(t, f, path) + if base == strings.TrimSpace(string(registryReportGit(t, f.root, "rev-parse", "HEAD^{tree}"))) { + t.Fatal("fixture did not exclude the tracked report") + } + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + first := assertRegistryReportBinding(t, f, path, base, "2.0.0", "2.0.0", false) + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + second := assertRegistryReportBinding(t, f, path, base, "2.0.0", "2.0.0", false) + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first, second) || !os.SameFile(info, afterInfo) { + t.Fatal("no-op report changed bytes or file identity") + } +} + +func TestPluginRegistrySyncReport_AtomicReplacement(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + path := filepath.Join(t.TempDir(), "operation.json") + if err := f.run("2.0.0", "--report", path); err != nil { + t.Fatal(err) + } + for _, original := range []string{path, f.manifest} { + link := filepath.Join(t.TempDir(), filepath.Base(original)) + if err := os.Link(original, link); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(link) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + after, err := os.ReadFile(link) + if err != nil || !bytes.Equal(before, after) { + t.Errorf("sync overwrote an existing inode in place: %s, %v", original, err) + } + }) + } + if err := f.run("1.0.0", "--report", path); err != nil { + t.Fatal(err) + } +} + +func TestPluginRegistrySyncReport_RejectsMutationWithoutWrites(t *testing.T) { + for _, kind := range []string{"release", "asset", "checksum", "mutable"} { + t.Run(kind, func(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + before, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + f.mutate = func(path string, n int, out map[string]any) string { + if n != 2 { + return "" + } + if strings.Contains(path, "/releases/tags/") { + switch kind { + case "release": + out["id"] = 999 + case "asset": + out["assets"].([]any)[1].(map[string]any)["size"] = 999 + case "mutable": + out["immutable"] = false + } + } + if kind == "checksum" && strings.HasPrefix(path, "/checksums/") { + return strings.Repeat("d", 64) + " workflow-plugin-foo-linux-amd64.tar.gz\n" + } + return "" + } + path := filepath.Join(t.TempDir(), "operation.json") + if err := f.run("2.0.0", "--report", path); err == nil { + t.Fatal("accepted a changed release") + } + after, err := os.ReadFile(f.manifest) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("failed reread changed manifest: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("failed reread wrote a report: %v", err) + } + }) + } +} + +func TestPluginRegistrySyncReport_RejectsOutputEscapeAndWriteFailure(t *testing.T) { + for _, kind := range []string{"relative escape", "manifest", "parent symlink", "blocked parent"} { + t.Run(kind, func(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + path := "../operation.json" + switch kind { + case "manifest": + path = f.manifest + case "parent symlink": + if err := os.Symlink(t.TempDir(), filepath.Join(f.root, "linked")); err != nil { + t.Fatal(err) + } + path = filepath.Join(f.root, "linked", "operation.json") + case "blocked parent": + mustWrite(t, filepath.Join(f.root, "blocked"), "file, not directory") + path = filepath.Join(f.root, "blocked", "operation.json") + } + before, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if err := f.run("2.0.0", "--report", path); err == nil { + t.Fatal("accepted invalid report destination") + } + after, err := os.ReadFile(f.manifest) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("invalid report destination changed manifest: %v", err) + } + }) + } +} diff --git a/cmd/wfctl/plugin_registry_sync_target.go b/cmd/wfctl/plugin_registry_sync_target.go new file mode 100644 index 00000000..e6ccff1d --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_target.go @@ -0,0 +1,370 @@ +package main + +import ( + "bytes" + stdcmp "cmp" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/mod/semver" +) + +type registrySyncGitObject struct { + Type string `json:"type"` + SHA string `json:"sha"` +} + +type registrySyncReleaseSnapshot struct { + Release *githubReleaseMetadata + TagObjects []registrySyncGitObject + SourceSHA string + ChecksumsSHA256 string + Checksums map[string]string + PluginJSON []byte + Downloads []releaseAsset +} + +func registrySyncStableTag(version string) (string, error) { + tag := "v" + strings.TrimPrefix(version, "v") + if !PublishGradeSemverRe.MatchString(tag) || !semver.IsValid(tag) || semver.Canonical(tag) != tag { + return "", fmt.Errorf("target-version %q must be an exact stable M.m.p version", version) + } + return tag, nil +} + +func syncRegistryTarget(registryDir, plugin, target string, fix, verifyCaps bool) error { + tag, err := registrySyncStableTag(target) + if err != nil { + return err + } + return syncRegistryRelease(registryDir, plugin, tag, "", fix, verifyCaps) +} + +func syncRegistryRelease(registryDir, plugin, tag, reportPath string, fix, verifyCaps bool) error { + manifestPath, err := registrySyncManifestPath(registryDir, plugin) + if err != nil { + return err + } + before, err := os.ReadFile(manifestPath) + if err != nil { + return err + } + var manifest map[string]any + if err := registrySyncDecodeJSON(before, &manifest); err != nil { + return fmt.Errorf("read registry manifest: %w", err) + } + oldVersion, _ := manifest["version"].(string) + var report *registrySyncReportContext + if reportPath != "" { + report, err = prepareRegistrySyncReport(registryDir, plugin, reportPath) + if err != nil { + return err + } + defer report.close() + } + manifestType, _ := manifest["type"].(string) + if !registryAllowedTypes[manifestType] || isCoreRegistryManifestType(manifestType) { + return fmt.Errorf("plugin %q type %q cannot be synced from a plugin release", plugin, manifestType) + } + repository, _ := manifest["repository"].(string) + if repository == "" { + repository, _ = manifest["source"].(string) + } + repo, err := registrySyncRepository(repository) + if err != nil { + return err + } + if tag == "" { + latest, err := ghReleaseLatestTag(repo) + if err != nil { + return err + } + tag, err = registrySyncStableTag(latest) + if err != nil { + return err + } + if current, err := registrySyncStableTag(oldVersion); err == nil && semver.Compare(current, tag) > 0 { + tag = current + } + } + first, err := registrySyncReadRelease(repo, tag) + if err != nil { + return err + } + after, err := registrySyncTargetManifest(manifest, first, strings.TrimPrefix(tag, "v")) + if err != nil { + return err + } + if after == nil { + after = before + } + if verifyCaps { + if err := verifyRegistryPluginCapabilities(plugin, manifestPath, repo, tag); err != nil { + return err + } + } + second, err := registrySyncReadRelease(repo, tag) + if err != nil { + return fmt.Errorf("release/source changed or became unreadable during sync: %w", err) + } + firstBytes, err := json.Marshal(first) + if err != nil { + return err + } + secondBytes, err := json.Marshal(second) + if err != nil { + return err + } + if !bytes.Equal(firstBytes, secondBytes) { + return fmt.Errorf("release, tag, source, assets or checksums changed during sync") + } + current, err := os.ReadFile(manifestPath) + if err != nil { + return err + } + if !bytes.Equal(before, current) { + return fmt.Errorf("registry manifest changed during sync") + } + if report != nil { + if err := report.write(plugin, repository, repo, oldVersion, strings.TrimPrefix(tag, "v"), first, after); err != nil { + return err + } + } + if bytes.Equal(before, after) { + fmt.Printf(" OK %s — %s\n", plugin, strings.TrimPrefix(tag, "v")) + return nil + } + if !fix { + return fmt.Errorf("plugin %q differs from target %s (use --fix)", plugin, tag) + } + if _, err := registrySyncManifestPath(registryDir, plugin); err != nil { + return err + } + info, err := os.Stat(manifestPath) + if err != nil { + return err + } + if err := atomicWriteFile(manifestPath, after, info.Mode().Perm()); err != nil { + return err + } + fmt.Printf(" FIXED %s — %s\n", plugin, strings.TrimPrefix(tag, "v")) + return nil +} + +func registrySyncManifestPath(registryDir, plugin string) (string, error) { + if plugin == "" || plugin == "." || plugin == ".." || strings.ContainsAny(plugin, `/\\`) || !filepath.IsLocal(plugin) { + return "", fmt.Errorf("--plugin must name one plugin directory without path separators") + } + root, err := filepath.Abs(registryDir) + if err != nil { + return "", err + } + path := root + for _, component := range []string{"plugins", plugin, "manifest.json"} { + path = filepath.Join(path, component) + info, err := os.Lstat(path) + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", fmt.Errorf("registry plugin path contains symlink: %s", path) + } + } + return path, nil +} + +func registrySyncRepository(repository string) (string, error) { + repo := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(repository, "https://"), "github.com/"), ".git") + parts := strings.Split(repo, "/") + if len(parts) != 2 { + return "", fmt.Errorf("plugin repository %q must name a GitHub owner/repository", repository) + } + for _, part := range parts { + if part == "" || part == "." || part == ".." || strings.ContainsAny(part, "?#%\\ \t\r\n") { + return "", fmt.Errorf("invalid plugin repository %q", repository) + } + } + return repo, nil +} + +func registrySyncReadRelease(repo, tag string) (*registrySyncReleaseSnapshot, error) { + apiRepo := "repos/" + escapedGitHubRepoPath(repo) + var ref struct { + Object registrySyncGitObject `json:"object"` + } + if err := registrySyncGitHubJSON(apiRepo+"/git/ref/tags/"+url.PathEscape(tag), &ref); err != nil { + return nil, err + } + snapshot := ®istrySyncReleaseSnapshot{TagObjects: []registrySyncGitObject{ref.Object}} + object := ref.Object + for depth := 0; object.Type == "tag" && depth < 8; depth++ { + if !registrySyncValidGitSHA(object.SHA) { + return nil, fmt.Errorf("invalid tag object SHA") + } + var annotated struct { + SHA string `json:"sha"` + Object registrySyncGitObject `json:"object"` + } + if err := registrySyncGitHubJSON(apiRepo+"/git/tags/"+object.SHA, &annotated); err != nil { + return nil, err + } + if annotated.SHA != object.SHA { + return nil, fmt.Errorf("tag object identity mismatch") + } + object = annotated.Object + snapshot.TagObjects = append(snapshot.TagObjects, object) + } + if object.Type != "commit" || !registrySyncValidGitSHA(object.SHA) { + return nil, fmt.Errorf("release tag must resolve to an exact commit SHA") + } + snapshot.SourceSHA = object.SHA + release, err := githubReleaseByTag(repo, tag) + if err != nil { + return nil, err + } + if release.ID <= 0 || release.TagName != tag || release.Draft || release.Prerelease || !release.Immutable { + return nil, fmt.Errorf("target must have an identified stable, published immutable release matching %s", tag) + } + slices.SortFunc(release.Assets, func(a, b githubReleaseAsset) int { return stdcmp.Compare(a.Name, b.Name) }) + snapshot.Release = release + var checksumURL string + seenNames, seenIDs := map[string]bool{}, map[int64]bool{} + for _, asset := range release.Assets { + if asset.ID <= 0 || seenNames[asset.Name] || seenIDs[asset.ID] || asset.Size < 0 || asset.BrowserDownloadURL == "" || asset.Name == "" || strings.ContainsAny(asset.Name, `/\\`) { + return nil, fmt.Errorf("release contains invalid or duplicate asset identity") + } + seenNames[asset.Name], seenIDs[asset.ID] = true, true + if asset.Name == "checksums.txt" { + checksumURL = asset.BrowserDownloadURL + } + } + if checksumURL == "" { + return nil, fmt.Errorf("release is missing checksums.txt") + } + checksumBytes, err := downloadURL(checksumURL) + if err != nil { + return nil, fmt.Errorf("read release checksums: %w", err) + } + snapshot.ChecksumsSHA256 = registrySyncSHA256(checksumBytes) + checksums, err := registrySyncStrictChecksums(string(checksumBytes)) + if err != nil { + return nil, err + } + snapshot.Checksums = checksums + seenPlatforms := map[string]bool{} + for _, asset := range release.Assets { + goos, goarch, ok := releaseAssetPlatform(asset.Name) + if !ok { + continue + } + checksum := checksums[asset.Name] + if checksum == "" || (asset.Digest != "" && asset.Digest != "sha256:"+checksum) { + return nil, fmt.Errorf("missing or inconsistent checksum for asset %s", asset.Name) + } + platform := goos + "/" + goarch + if seenPlatforms[platform] { + return nil, fmt.Errorf("duplicate release platform %s", platform) + } + seenPlatforms[platform] = true + snapshot.Downloads = append(snapshot.Downloads, releaseAsset{Name: asset.Name, OS: goos, Arch: goarch, URL: asset.BrowserDownloadURL, SHA256: checksum}) + } + if len(snapshot.Downloads) == 0 { + return nil, fmt.Errorf("release has no platform assets") + } + var content struct { + Encoding string `json:"encoding"` + Content string `json:"content"` + } + if err := registrySyncGitHubJSON(apiRepo+"/contents/plugin.json?ref="+snapshot.SourceSHA, &content); err != nil { + return nil, err + } + if content.Encoding != "base64" { + return nil, fmt.Errorf("source plugin.json must have base64 encoding") + } + snapshot.PluginJSON, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(content.Content, "\n", "")) + if err != nil { + return nil, fmt.Errorf("decode source plugin.json: %w", err) + } + return snapshot, nil +} + +func registrySyncTargetManifest(manifest map[string]any, snapshot *registrySyncReleaseSnapshot, target string) ([]byte, error) { + before, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + var pluginJSON map[string]any + if err := registrySyncDecodeJSON(snapshot.PluginJSON, &pluginJSON); err != nil { + return nil, fmt.Errorf("parse pinned source plugin.json: %w", err) + } + if version, ok := pluginJSON["version"].(string); ok && strings.TrimPrefix(version, "v") != target { + return nil, fmt.Errorf("pinned plugin.json version does not match target release") + } + manifest["version"] = target + downloads := make([]any, 0, len(snapshot.Downloads)) + for _, asset := range snapshot.Downloads { + downloads = append(downloads, map[string]any{"os": asset.OS, "arch": asset.Arch, "url": asset.URL, "sha256": asset.SHA256}) + } + manifest["downloads"] = downloads + syncManifestMetadataFromPluginJSON(manifest, pluginJSON) + after, err := json.Marshal(manifest) + if err != nil { + return nil, err + } + if bytes.Equal(before, after) { + return nil, nil + } + formatted, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, err + } + return append(formatted, '\n'), nil +} + +func registrySyncStrictChecksums(text string) (map[string]string, error) { + checksums := map[string]string{} + for line := range strings.SplitSeq(text, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + hash, name, ok := strings.Cut(strings.TrimSpace(line), " ") + name = strings.TrimPrefix(strings.TrimSpace(name), "*") + sha, err := NormalizeSHA256Hex(hash) + if !ok || err != nil || name == "" || strings.ContainsAny(name, `/\\`) || checksums[name] != "" { + return nil, fmt.Errorf("release checksums contain invalid or duplicate entries") + } + checksums[name] = sha + } + return checksums, nil +} + +func registrySyncDecodeJSON(data []byte, result any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(result); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("JSON contains trailing data: %v", err) + } + return nil +} + +func registrySyncValidGitSHA(value string) bool { + decoded, err := hex.DecodeString(value) + return err == nil && len(decoded) == 20 && strings.ToLower(value) == value +} + +func registrySyncSHA256(data []byte) string { + hash := sha256.Sum256(data) + return hex.EncodeToString(hash[:]) +} diff --git a/cmd/wfctl/plugin_registry_sync_target_test.go b/cmd/wfctl/plugin_registry_sync_target_test.go new file mode 100644 index 00000000..abbb61cf --- /dev/null +++ b/cmd/wfctl/plugin_registry_sync_target_test.go @@ -0,0 +1,293 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" +) + +type registrySyncTargetFixture struct { + root string + manifest string + mutate func(string, int, map[string]any) string + latest atomic.Int32 + apiURL string + handler http.Handler +} + +func newRegistrySyncTargetFixture(t *testing.T) *registrySyncTargetFixture { + t.Helper() + f := ®istrySyncTargetFixture{root: t.TempDir()} + f.manifest = filepath.Join(f.root, "plugins", "foo", "manifest.json") + mustWrite(t, f.manifest, `{"name":"workflow-plugin-foo","version":"1.0.0","repository":"https://github.com/owner/repo","type":"external","description":"preserved"}`+"\n") + counts := make(map[string]int) + var countsMu sync.Mutex + f.handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + countsMu.Lock() + counts[r.URL.Path]++ + n := counts[r.URL.Path] + countsMu.Unlock() + var out map[string]any + switch { + case r.URL.Path == "/repos/owner/repo/releases/latest": + f.latest.Add(1) + out = map[string]any{"tag_name": "v2.0.0"} + case strings.HasPrefix(r.URL.Path, "/repos/owner/repo/releases/tags/"): + tag := strings.TrimPrefix(r.URL.Path, "/repos/owner/repo/releases/tags/") + out = map[string]any{ + "id": 100, "tag_name": tag, "target_commitish": strings.Repeat("a", 40), + "draft": false, "prerelease": false, "immutable": true, + "assets": []any{ + map[string]any{"id": 200, "name": "checksums.txt", "size": 105, "browser_download_url": f.apiURL + "/checksums/" + tag}, + map[string]any{"id": 201, "name": "workflow-plugin-foo-linux-amd64.tar.gz", "size": 42, "browser_download_url": f.apiURL + "/assets/" + tag + "/workflow-plugin-foo-linux-amd64.tar.gz"}, + }, + } + case strings.HasPrefix(r.URL.Path, "/repos/owner/repo/git/ref/tags/"): + out = map[string]any{"object": map[string]any{"type": "tag", "sha": strings.Repeat("b", 40)}} + case r.URL.Path == "/repos/owner/repo/git/tags/"+strings.Repeat("b", 40): + out = map[string]any{"sha": strings.Repeat("b", 40), "object": map[string]any{"type": "commit", "sha": strings.Repeat("a", 40)}} + case strings.HasPrefix(r.URL.Path, "/checksums/"): + out = map[string]any{} + case r.URL.Path == "/repos/owner/repo/contents/plugin.json": + if r.URL.Query().Get("ref") != strings.Repeat("a", 40) { + http.NotFound(w, r) + return + } + out = map[string]any{"encoding": "base64", "content": base64.StdEncoding.EncodeToString([]byte(`{"name":"workflow-plugin-foo","type":"external","capabilities":{"services":["foo"]}}`))} + default: + http.NotFound(w, r) + return + } + if f.mutate != nil { + if body := f.mutate(r.URL.Path, n, out); body != "" { + fmt.Fprint(w, body) + return + } + } + if strings.HasPrefix(r.URL.Path, "/checksums/") { + fmt.Fprintln(w, strings.Repeat("c", 64)+" workflow-plugin-foo-linux-amd64.tar.gz") + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(out); err != nil { + t.Error(err) + } + }) + srv := httptest.NewServer(f.handler) + t.Cleanup(srv.Close) + f.apiURL = srv.URL + oldURL, oldClient := gitHubAPIBaseURL, gitHubAPIClient + gitHubAPIBaseURL, gitHubAPIClient = srv.URL, srv.Client() + t.Cleanup(func() { gitHubAPIBaseURL, gitHubAPIClient = oldURL, oldClient }) + return f +} + +func (f *registrySyncTargetFixture) run(target string, extra ...string) error { + args := []string{"--registry-dir", f.root, "--plugin", "foo", "--target-version", target, "--fix"} + return runPluginRegistrySync(append(args, extra...)) +} + +func TestPluginRegistrySyncTarget_ForwardNoopRollback(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + previous := map[string][]byte{} + for _, target := range []string{"2.0.0", "2.0.0", "v1.0.0", "1.0.0"} { + beforeInfo, err := os.Stat(f.manifest) + if err != nil { + t.Fatal(err) + } + if err := f.run(target); err != nil { + t.Fatalf("target %s: %v", target, err) + } + after, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(after, &got); err != nil { + t.Fatal(err) + } + if got["version"] != strings.TrimPrefix(target, "v") || got["description"] != "preserved" { + t.Fatalf("manifest = %s", after) + } + version := strings.TrimPrefix(target, "v") + if prior := previous[version]; prior != nil { + if !bytes.Equal(prior, after) { + t.Fatal("repeated sync changed manifest bytes") + } + afterInfo, err := os.Stat(f.manifest) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(beforeInfo, afterInfo) { + t.Fatal("no-op replaced manifest instead of preserving it") + } + } + previous[version] = after + } + if f.latest.Load() != 0 { + t.Fatal("explicit target consulted the floating latest release") + } +} + +func TestPluginRegistrySyncTarget_ActualCLI(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + commitRegistryReportFixture(t, f) + reportPath := filepath.Join(f.root, ".github", "registry-operations", "operation.json") + binary := registrySyncActualCLIBinary(t, runtime.GOOS, f.apiURL) + env := registrySyncHostEnvironment(os.Environ()) + var proxy *registrySyncReleaseProxy + if runtime.GOOS == "linux" { + proxy = newRegistrySyncReleaseProxy(t, f.handler) + env = append(env, "HTTPS_PROXY="+proxy.url, "NO_PROXY=127.0.0.1,localhost", "SSL_CERT_FILE="+proxy.caFile, "SSL_CERT_DIR="+proxy.caDirectory) + } + oldVersion := "1.0.0" + var priorNoop []byte + for _, version := range []string{"2.0.0", "2.0.0", "2.0.0", "1.0.0", "1.0.0", "1.0.0"} { + base := registryReportFixtureTree(t, f, reportPath) + cmd := exec.CommandContext(t.Context(), binary, "plugin", "registry-sync", "--registry-dir", f.root, "--plugin", "foo", "--target-version", version, "--fix", "--report", reportPath) + cmd.Env = env + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("actual CLI target %s: %v\n%s", version, err, out) + } + data, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + var manifest map[string]any + if err := json.Unmarshal(data, &manifest); err != nil { + t.Fatal(err) + } + if manifest["version"] != version { + t.Fatalf("CLI result = %s", data) + } + changed := oldVersion != version + report := assertRegistryReportBinding(t, f, reportPath, base, oldVersion, version, changed) + if !changed { + if priorNoop != nil && !bytes.Equal(priorNoop, report) { + t.Fatal("actual CLI repeated no-op report changed bytes") + } + priorNoop = report + } else { + priorNoop = nil + } + oldVersion = version + } + if f.latest.Load() != 0 { + t.Fatal("actual CLI used floating latest for an exact target") + } + if proxy != nil && (proxy.connects.Load() == 0 || proxy.requests.Load() == 0) { + t.Fatal("actual Linux CLI bypassed the default-api.github.com CONNECT/TLS fixture") + } +} + +func TestPluginRegistrySyncTarget_RejectsMutationBeforeWrite(t *testing.T) { + for _, kind := range []string{"tag", "source", "release", "asset", "checksum", "metadata"} { + t.Run(kind, func(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + before, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + f.mutate = func(path string, n int, out map[string]any) string { + if n != 2 { + return "" + } + switch { + case kind == "tag" && strings.Contains(path, "/git/ref/"): + out["object"].(map[string]any)["sha"] = strings.Repeat("d", 40) + case kind == "source" && strings.Contains(path, "/git/tags/"): + out["object"].(map[string]any)["sha"] = strings.Repeat("d", 40) + case kind == "release" && strings.Contains(path, "/releases/tags/"): + out["id"] = 101 + case kind == "asset" && strings.Contains(path, "/releases/tags/"): + out["assets"].([]any)[1].(map[string]any)["id"] = 202 + case kind == "checksum" && strings.HasPrefix(path, "/checksums/"): + return strings.Repeat("e", 64) + " workflow-plugin-foo-linux-amd64.tar.gz\n" + case kind == "metadata" && strings.HasSuffix(path, "/plugin.json"): + out["content"] = base64.StdEncoding.EncodeToString([]byte(`{"type":"external","capabilities":{"services":["changed"]}}`)) + } + return "" + } + if err := f.run("2.0.0"); err == nil || !strings.Contains(err.Error(), "changed") { + t.Fatalf("expected mutation rejection, got %v", err) + } + after, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("failed sync mutated manifest") + } + }) + } +} + +func TestPluginRegistrySyncTarget_RejectsInvalidSelection(t *testing.T) { + for _, target := range []string{"", "latest", "1.2", "1.2.3-rc.1", "1.2.3+build", "01.2.3", "../1.2.3"} { + t.Run(target, func(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + if err := f.run(target); err == nil || !strings.Contains(err.Error(), "stable") { + t.Fatalf("expected stable-version rejection, got %v", err) + } + }) + } +} + +func TestPluginRegistrySyncTarget_RejectsPluginEscape(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + if err := f.run("2.0.0", "--plugin", "../foo"); err == nil || !strings.Contains(err.Error(), "plugin") || strings.Contains(err.Error(), "flag provided") { + t.Fatalf("expected plugin-path rejection, got %v", err) + } + outside := t.TempDir() + if err := os.Symlink(outside, filepath.Join(f.root, "plugins", "escape")); err != nil { + t.Fatal(err) + } + if err := f.run("2.0.0", "--plugin", "escape"); err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected symlink rejection, got %v", err) + } +} + +func TestPluginRegistrySyncTarget_PreservesExactManifestNumbers(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + mustWrite(t, f.manifest, `{"name":"workflow-plugin-foo","version":"1.0.0","repository":"https://github.com/owner/repo","type":"external","opaque_counter":9007199254740993}`) + if err := f.run("2.0.0"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(data, []byte(`"opaque_counter": 9007199254740993`)) { + t.Fatalf("sync changed an unrelated exact JSON integer: %s", data) + } +} + +func TestPluginRegistrySyncTarget_DryRunDoesNotWrite(t *testing.T) { + f := newRegistrySyncTargetFixture(t) + before, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + err = runPluginRegistrySync([]string{"--registry-dir", f.root, "--plugin", "foo", "--target-version", "2.0.0"}) + if err == nil || !strings.Contains(err.Error(), "--fix") { + t.Fatalf("dry-run should report drift, got %v", err) + } + after, err := os.ReadFile(f.manifest) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("dry-run wrote manifest") + } +} diff --git a/cmd/wfctl/testdata/pipeline-record/README.md b/cmd/wfctl/testdata/pipeline-record/README.md new file mode 100644 index 00000000..1f81cff4 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/README.md @@ -0,0 +1,98 @@ +# Record Host Fixtures + +`plugin/` is a real external plugin using the Workflow SDK and an embedded, +matching manifest. It exports a primary step plus an auxiliary catalog type, +and no config fragment. Its execution +audit proves that each failure test reached the SDK step rather than failing +during loader or Docker preflight. Successful output contains no numbers. +Runtime identity/catalog mismatch tests edit only the installed disk manifest +and require the SDK startup audit with no step execution marker. +`foreach.yaml`, `parallel.yaml`, `while.yaml`, `retry-with-backoff.yaml`, and +`resilient-circuit-breaker.yaml` drive the real composite implementations with +SDK sub-steps. Their top-level `result` selects nested SDK fields and stringified +engine counters, not a constant success marker. Each runs with two different +caller-supplied participant pools; exact SDK audits check resolved config, +parent input, prior step output, invocation counts, and ordered retry/fallback. +`compensation.yaml` requires SDK compensation calls in reverse order followed +by the fixed public error record, never the unreachable success result. +All cases require SDK process reaping and empty cleanup journals. The fixture +only supplies operation responses; it does not implement the composites. +SDK outputs are promoted into the runtime's flat `Current` map. Actor lookups +after an SDK call use the existing `.trigger.pool` namespace so the SDK's +string-valued `pool` output cannot shadow the caller's original collection. +The `wait` mode records entry to Execute and waits for RPC cancellation, providing +a readiness boundary for crash tests without introducing a second CLI executor. + +`pipeline_record_host_test.go` builds the actual `wfctl` and plugin binaries +with race instrumentation unless a downloaded host binary is explicitly +selected below. The ordinary host tests substitute only `docker/`, +a Docker CLI dependency answering context show/inspect, info, and empty ps +queries. Without an explicit transport fixture it refuses container operations. Engine, +child isolation, real SDK RPC, framing, canonical encoding, and public CLI +stdout are not substituted. These tests do not prove live Docker health or +container execution. All temporary paths are canonicalized before journal use. + +Run from the repository root with `GOWORK=off GOTOOLCHAIN=go1.26.5`: + +```sh +go test ./cmd/wfctl -run '^TestPipelineRecordHost' -count=1 +``` + +For Task 35 step 10, first verify the selected release archive/checksum and +check out its exact source tag for the SDK fixture and tests. Then opt in: + +```sh +GOWORK=off GOTOOLCHAIN=go1.26.5 \ + WFCTL_RECORD_HOST_BINARY=/absolute/path/to/verified/wfctl \ + go test ./cmd/wfctl -run '^TestPipelineRecordHost' -count=1 +``` + +Only the host build target `.` is replaced. The helper copies the selected +regular file byte-for-byte to the test's host path with mode `0700`; missing, +relative, directory, or symlink selections fail without a source-build +fallback. SDK and Docker dependency fixtures still build normally. The +downloaded host is not rebuilt or race-instrumented by this harness. Caller +checksum verification remains required; this selector does not verify a +release. `TestPipelineRecordHostPrebuiltBinary` uses sentinel bytes only to +test copy/fail-closed behavior, not to claim runtime or release conformance. + +The opt-in `TestPipelineRecordHostLiveDockerBuiltin` uses the real Docker CLI +and daemon, requires `WFCTL_RECORD_HOST_LIVE_DOCKER=1`, and exercises only a +builtin pipeline plus lifecycle preflight/cleanup. It creates no containers. +Skipped or fake-CLI tests are not live Docker evidence. + +`TestPipelineRecordHostLiveDockerSandbox` additionally launches a real strict +sandbox container through the built CLI, Workflow engine, and parent broker. +It requires `WFCTL_RECORD_HOST_LIVE_IMAGE` to name a pre-pulled digest-pinned +BusyBox-compatible image. Its command checks UID/GID, dropped capabilities, +no-new-privileges, read-only root, non-executable `/tmp`, owner-only `/work`, +absence of host credentials, and a request-supplied value read back from tmpfs. +On GitHub Actions Linux runners, the sandbox test runs automatically through +the existing CI test command: it pulls the public image, resolves its immutable +digest, and requires final journal/container readback to be empty. No additional +CI authority or workflow trust-policy change is needed. + +`cleanup.yaml` drives the real SDK, Workflow sandbox step, private WFD2 broker, +and parent Docker adapter. Cleanup-matrix tests enable a dependency-only Docker +transport state file: create writes a fake full ID/CID file and label state; +start/wait return fixture output; inspect/ps/rm reconcile that state. No image, +container, command, or isolation boundary is executed by this transport. +An optional create gate and TERM-resistant late-create helper use explicit +ready/release files. Tests kill only the actual host parent after readiness, +then invoke the built CLI again against the same journal/daemon identity. +PID start tokens and process groups are checked; non-running zombies are not +treated as live actors capable of a late create. + +`late-stdout.yaml` arms an emitter in one successful SDK Execute RPC. A second +SDK Execute receives the first step's output via the real host, records that +response barrier, and releases the emitter. The host test requires a separate +audit written after a stdout write completes. The oversized case audits a +completed 64-byte late prefix before attempting the tail of its +1 MiB-plus-one-byte payload. This is an attempted oversized late write +interrupted by zero-stdout quarantine, which cancels at the first byte; it +does not prove full payload transmission or a stdout-overflow flag trip. +Actual prefix-write counts and requested size are checked; cap enforcement +remains covered by stderr-overflow and capture tests. No sleep is used to manufacture +late output that shutdown could kill before emission. Both ordinary and oversized +late stdout are tested, along with bounded stderr and stderr overflow. The +descriptor mode probes FD3 through FD6, including private result FD4/control FD5. diff --git a/cmd/wfctl/testdata/pipeline-record/branch.yaml b/cmd/wfctl/testdata/pipeline-record/branch.yaml new file mode 100644 index 00000000..5040855e --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/branch.yaml @@ -0,0 +1,28 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: host-prepared + - name: result + type: step.branch + config: + field: mode + branches: + success: + - name: nested-sdk + type: step.record_fixture + config: + mode: success + audit_path: "{{ .audit_path }}" + greeting: "{{ .steps.prepare.greeting }}" + stopped-error: + - name: stopped-sdk + type: step.record_fixture + config: + mode: stopped-error + audit_path: "{{ .audit_path }}" + greeting: "{{ .steps.prepare.greeting }}" diff --git a/cmd/wfctl/testdata/pipeline-record/builtin.yaml b/cmd/wfctl/testdata/pipeline-record/builtin.yaml new file mode 100644 index 00000000..86d185e0 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/builtin.yaml @@ -0,0 +1,10 @@ +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.set + config: + values: + ready: true + source: builtin diff --git a/cmd/wfctl/testdata/pipeline-record/cleanup.yaml b/cmd/wfctl/testdata/pipeline-record/cleanup.yaml new file mode 100644 index 00000000..1781ad27 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/cleanup.yaml @@ -0,0 +1,35 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: host-prepared + - name: before + type: step.record_fixture + config: + mode: "{{ .before_mode }}" + audit_path: "{{ .before_audit }}" + greeting: "{{ .steps.prepare.greeting }}" + - name: command + type: step.sandbox_exec + config: + image: alpine:3.21 + command: [sh, -euc, "printf '%s' 'fixture-command'"] + security_profile: strict + cpu_limit: 1 + timeout: 30s + - name: after + type: step.record_fixture + config: + mode: "{{ .after_mode }}" + audit_path: "{{ .after_audit }}" + greeting: "{{ .steps.prepare.greeting }}" + - name: result + type: step.set + config: + values: + ready: true + source: cleanup-fixture diff --git a/cmd/wfctl/testdata/pipeline-record/cli-print.yaml b/cmd/wfctl/testdata/pipeline-record/cli-print.yaml new file mode 100644 index 00000000..73804971 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/cli-print.yaml @@ -0,0 +1,13 @@ +modules: [] +pipelines: + selected: + steps: + - name: hostile-output + type: step.cli_print + config: + message: 'SDK_RECORD_V1 {"forged":true} fixture-cli-stdout-canary' + - name: result + type: step.set + config: + values: + ready: true diff --git a/cmd/wfctl/testdata/pipeline-record/compensation.yaml b/cmd/wfctl/testdata/pipeline-record/compensation.yaml new file mode 100644 index 00000000..491a6733 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/compensation.yaml @@ -0,0 +1,52 @@ +modules: [] +pipelines: + selected: + on_error: compensate + steps: + - name: prepare + type: step.set + config: + values: + greeting: "{{ .pool_name }}" + - name: forward + type: step.record_fixture + config: + mode: composite + audit_path: "{{ .audit_path }}" + participant: "{{ index .trigger.pool 0 }}" + pool: "{{ .pool_name }}" + ordinal: "0" + action: forward + - name: fail + type: step.record_fixture_aux + config: + mode: composite-error + audit_path: "{{ .audit_path }}" + participant: "{{ last .trigger.pool }}" + pool: "{{ .pool_name }}" + ordinal: "{{ .last_ordinal }}" + action: fail + - name: result + type: step.set + config: + values: + false_success: true + compensation: + - name: undo-first + type: step.record_fixture + config: + mode: composite-compensate + audit_path: "{{ .audit_path }}" + participant: "{{ index .trigger.pool 0 }}" + pool: "{{ .pool_name }}" + ordinal: "0" + action: undo-first + - name: undo-last + type: step.record_fixture_aux + config: + mode: composite-compensate + audit_path: "{{ .audit_path }}" + participant: "{{ last .trigger.pool }}" + pool: "{{ .pool_name }}" + ordinal: "{{ .last_ordinal }}" + action: undo-last diff --git a/cmd/wfctl/testdata/pipeline-record/docker/main.go b/cmd/wfctl/testdata/pipeline-record/docker/main.go new file mode 100644 index 00000000..3ee85bc9 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/docker/main.go @@ -0,0 +1,63 @@ +//go:build linux || darwin + +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "--record-fixture-late-create" { + runRecordDockerLateCreate() + return + } + args := os.Args[1:] + for len(args) > 0 && strings.HasPrefix(args[0], "-") { + flag, _, inline := strings.Cut(args[0], "=") + args = args[1:] + if !inline && flag != "--tls" && flag != "--tlsverify" { + if len(args) == 0 { + os.Exit(64) + } + args = args[1:] + } + } + if len(args) == 0 { + os.Exit(64) + } + if runRecordDockerTransport(args) { + return + } + // Only daemon identity and empty lifecycle queries are substituted. A + // container operation is never simulated by this fixture. + switch args[0] { + case "context": + if len(args) < 2 { + os.Exit(64) + } + switch args[1] { + case "show": + fmt.Println("record-fixture-context") + case "inspect": + fmt.Println(`[{"Name":"record-fixture-context","Endpoints":{"docker":{"Host":"unix:///record-fixture-daemon.sock","SkipTLSVerify":false}},"TLSMaterial":{},"Storage":{}}]`) + default: + os.Exit(64) + } + case "info": + for _, arg := range args[1:] { + if arg == "{{json .ID}}" { + _ = json.NewEncoder(os.Stdout).Encode("record-fixture-daemon") + return + } + } + fmt.Println("record-fixture-daemon") + case "ps": + // This test dependency has no containers, including cleanup-label matches. + default: + fmt.Fprintln(os.Stderr, "fixture Docker supports only context, info, and empty ps queries") + os.Exit(64) + } +} diff --git a/cmd/wfctl/testdata/pipeline-record/docker/transport.go b/cmd/wfctl/testdata/pipeline-record/docker/transport.go new file mode 100644 index 00000000..f7da11b1 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/docker/transport.go @@ -0,0 +1,216 @@ +//go:build linux || darwin + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "slices" + "strings" + "syscall" + "time" +) + +type transportConfig struct { + Root string `json:"root"` + BlockCreate bool `json:"block_create"` + LateCreate bool `json:"late_create"` + Unavailable bool `json:"unavailable"` +} + +const transportID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +// This dependency records Docker transport events, not container execution. +// Gates wait for explicit test releases; no lifecycle race depends on a sleep. +func runRecordDockerTransport(args []string) bool { + binary, err := os.Executable() + if err != nil { + os.Exit(65) + } + data, err := os.ReadFile(filepath.Join(filepath.Dir(binary), "transport.json")) + if errors.Is(err, os.ErrNotExist) { + return false + } + var cfg transportConfig + if err != nil || json.Unmarshal(data, &cfg) != nil || !filepath.IsAbs(cfg.Root) { + os.Exit(65) + } + log, err := os.OpenFile(filepath.Join(cfg.Root, "calls.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + if err != nil { + os.Exit(65) + } + err = json.NewEncoder(log).Encode(map[string]any{"pid": os.Getpid(), "pgid": syscall.Getpgrp(), "args": args}) + if errors.Join(err, log.Sync(), log.Close()) != nil { + os.Exit(65) + } + if args[0] == "context" { + return false + } + if args[0] == "info" { + if cfg.Unavailable { + fmt.Fprintln(os.Stderr, "fixture-unavailable-daemon-private-canary") + os.Exit(7) + } + return false + } + state, err := readTransportState(cfg.Root) + if err != nil { + os.Exit(65) + } + switch args[0] { + case "create": + label := strings.TrimPrefix(transportFlag(args, "--label"), "wfctl.pipeline.cleanup=") + cidfile := transportFlag(args, "--cidfile") + if label == "" || cidfile == "" { + os.Exit(65) + } + if cfg.LateCreate { + helper := exec.Command(binary, "--record-fixture-late-create", cfg.Root, label, cidfile) + helper.Stdout, helper.Stderr = os.Stdout, os.Stderr + if err := helper.Start(); err != nil { + os.Exit(65) + } + go func() { _ = helper.Wait() }() + } + if cfg.BlockCreate { + if writeTransportJSON(filepath.Join(cfg.Root, "create.ready"), map[string]any{"pid": os.Getpid(), "pgid": syscall.Getpgrp(), "label": label}) != nil || + waitTransportRelease(filepath.Join(cfg.Root, "create.release")) != nil { + os.Exit(65) + } + } + if createTransportContainer(cfg.Root, label, cidfile) != nil { + os.Exit(65) + } + fmt.Println(transportID) + case "start", "wait": + if len(args) < 2 || state[args[len(args)-1]] == "" { + os.Exit(65) + } + if args[0] == "start" { + fmt.Print("captured output") + } else { + fmt.Println("0") + } + case "ps": + filter := transportFlag(args, "--filter") + var ids []string + for id, label := range state { + if filter == "id="+id || filter == "label=wfctl.pipeline.cleanup="+label { + ids = append(ids, id) + } + } + slices.Sort(ids) + for _, id := range ids { + fmt.Println(id) + } + case "inspect": + id := args[len(args)-1] + label, exists := state[id] + if !exists { + os.Exit(1) + } + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{"id": id, "labels": map[string]string{"wfctl.pipeline.cleanup": label}}) + case "rm": + delete(state, args[len(args)-1]) + if writeTransportJSON(filepath.Join(cfg.Root, "containers.json"), state) != nil { + os.Exit(65) + } + default: + os.Exit(64) + } + return true +} + +func transportFlag(args []string, name string) string { + for i, arg := range args { + if value, ok := strings.CutPrefix(arg, name+"="); ok { + return value + } + if arg == name && i+1 < len(args) { + return args[i+1] + } + } + return "" +} + +func readTransportState(root string) (map[string]string, error) { + state := map[string]string{} + data, err := os.ReadFile(filepath.Join(root, "containers.json")) + if errors.Is(err, os.ErrNotExist) { + return state, nil + } + if err != nil { + return nil, err + } + return state, json.Unmarshal(data, &state) +} + +func createTransportContainer(root, label, cidfile string) error { + state, err := readTransportState(root) + if err != nil { + return err + } + state[transportID] = label + if err := writeTransportJSON(filepath.Join(root, "containers.json"), state); err != nil { + return err + } + return os.WriteFile(cidfile, []byte(transportID+"\n"), 0600) +} + +func writeTransportJSON(path string, value any) error { + data, err := json.Marshal(value) + if err != nil { + return err + } + file, err := os.CreateTemp(filepath.Dir(path), ".fixture-") + if err != nil { + return err + } + defer os.Remove(file.Name()) + _, err = file.Write(append(data, '\n')) + if err := errors.Join(err, file.Sync(), file.Close()); err != nil { + return err + } + return os.Rename(file.Name(), path) +} + +func waitTransportRelease(path string) error { + ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second) + defer cancel() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + for { + if _, err := os.Stat(path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func runRecordDockerLateCreate() { + if len(os.Args) != 5 { + os.Exit(65) + } + root, label, cidfile := os.Args[2], os.Args[3], os.Args[4] + signal.Ignore(syscall.SIGTERM) + if writeTransportJSON(filepath.Join(root, "late.ready"), map[string]any{"pid": os.Getpid(), "pgid": syscall.Getpgrp(), "label": label}) != nil || + waitTransportRelease(filepath.Join(root, "late.release")) != nil { + os.Exit(65) + } + if createTransportContainer(root, label, cidfile) != nil || + writeTransportJSON(filepath.Join(root, "late.created"), map[string]any{"pid": os.Getpid(), "label": label}) != nil { + os.Exit(65) + } +} diff --git a/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go b/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go new file mode 100644 index 00000000..9bf4d3b9 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/docker/transport_test.go @@ -0,0 +1,49 @@ +//go:build linux || darwin + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestTransportCreateWritesOwnedStateAndCID(t *testing.T) { + root := t.TempDir() + cidfile := filepath.Join(root, "fixture.cid") + if err := createTransportContainer(root, "fixture-label", cidfile); err != nil { + t.Fatal(err) + } + state, err := readTransportState(root) + if err != nil || len(state) != 1 || state[transportID] != "fixture-label" { + t.Fatalf("transport lost its full ID/label state: %v, %v", state, err) + } + data, err := os.ReadFile(cidfile) + if err != nil || string(data) != transportID+"\n" { + t.Fatalf("transport CID differs from create output: %q, %v", data, err) + } +} + +func TestTransportReleaseUsesExplicitEvent(t *testing.T) { + path := filepath.Join(t.TempDir(), "release") + if err := os.WriteFile(path, nil, 0600); err != nil { + t.Fatal(err) + } + if err := waitTransportRelease(path); err != nil { + t.Fatalf("existing release event was ignored: %v", err) + } +} + +func TestTransportFlagForms(t *testing.T) { + for _, args := range [][]string{ + {"create", "--label", "wfctl.pipeline.cleanup=fixture-label", "--cidfile", "fixture.cid"}, + {"create", "--label=wfctl.pipeline.cleanup=fixture-label", "--cidfile=fixture.cid"}, + } { + if got := transportFlag(args, "--label"); got != "wfctl.pipeline.cleanup=fixture-label" { + t.Fatalf("label argument was not preserved: %q", got) + } + if got := transportFlag(args, "--cidfile"); got != "fixture.cid" { + t.Fatalf("CID argument was not preserved: %q", got) + } + } +} diff --git a/cmd/wfctl/testdata/pipeline-record/external.yaml b/cmd/wfctl/testdata/pipeline-record/external.yaml new file mode 100644 index 00000000..0e963bb0 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/external.yaml @@ -0,0 +1,15 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: host-prepared + - name: result + type: step.record_fixture + config: + mode: "{{ .mode }}" + audit_path: "{{ .audit_path }}" + greeting: "{{ .steps.prepare.greeting }}" diff --git a/cmd/wfctl/testdata/pipeline-record/foreach.yaml b/cmd/wfctl/testdata/pipeline-record/foreach.yaml new file mode 100644 index 00000000..9a69b565 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/foreach.yaml @@ -0,0 +1,35 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: "{{ .pool_name }}" + - name: fanout + type: step.foreach + config: + collection: pool + concurrency: 2 + step: + name: visit + type: step.record_fixture + config: + mode: composite + audit_path: "{{ .audit_path }}" + participant: "{{ .item }}" + pool: "{{ .pool_name }}" + ordinal: "{{ .foreach.index }}" + action: visit + - name: result + type: step.set + config: + values: + source: foreach + count: "{{ .steps.fanout.count }}" + first: "{{ (index .steps.fanout.results 0).participant }}" + last: "{{ (last .steps.fanout.results).participant }}" + pool: "{{ (index .steps.fanout.results 0).pool }}" + echo: "{{ (last .steps.fanout.results).echo }}" + ready: "{{ (last .steps.fanout.results).ready }}" diff --git a/cmd/wfctl/testdata/pipeline-record/late-stdout.yaml b/cmd/wfctl/testdata/pipeline-record/late-stdout.yaml new file mode 100644 index 00000000..885e20c4 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/late-stdout.yaml @@ -0,0 +1,22 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: host-prepared + - name: arm + type: step.record_fixture + config: + mode: arm-late-stdout + late_mode: "{{ .mode }}" + audit_path: "{{ .audit_path }}.armed" + emission_path: "{{ .audit_path }}.emitted" + - name: result + type: step.record_fixture_aux + config: + mode: "{{ .mode }}" + audit_path: "{{ .audit_path }}" + greeting: "{{ .steps.prepare.greeting }}" diff --git a/cmd/wfctl/testdata/pipeline-record/missing-result.yaml b/cmd/wfctl/testdata/pipeline-record/missing-result.yaml new file mode 100644 index 00000000..7e2f3476 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/missing-result.yaml @@ -0,0 +1,14 @@ +modules: [] +pipelines: + selected: + steps: + - name: stopper + type: step.record_fixture + config: + mode: stop + audit_path: "{{ .audit_path }}" + - name: result + type: step.set + config: + values: + ready: true diff --git a/cmd/wfctl/testdata/pipeline-record/parallel.yaml b/cmd/wfctl/testdata/pipeline-record/parallel.yaml new file mode 100644 index 00000000..9c9df8b0 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/parallel.yaml @@ -0,0 +1,43 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: "{{ .pool_name }}" + - name: fanout + type: step.parallel + config: + steps: + - name: left + type: step.record_fixture + config: + mode: composite + audit_path: "{{ .audit_path }}" + participant: "{{ index .pool 0 }}" + pool: "{{ .pool_name }}" + ordinal: "0" + action: left + - name: right + type: step.record_fixture_aux + config: + mode: composite + audit_path: "{{ .audit_path }}" + participant: "{{ index .pool (sub (len .pool) 1) }}" + pool: "{{ .pool_name }}" + ordinal: "{{ .last_ordinal }}" + action: right + - name: result + type: step.set + config: + values: + source: parallel + completed: "{{ .steps.fanout.completed }}" + failed: "{{ .steps.fanout.failed }}" + left: "{{ .steps.fanout.results.left.participant }}" + right: "{{ .steps.fanout.results.right.participant }}" + pool: "{{ .steps.fanout.results.left.pool }}" + echo: "{{ .steps.fanout.results.right.echo }}" + ready: "{{ .steps.fanout.results.right.ready }}" diff --git a/cmd/wfctl/testdata/pipeline-record/plugin/main.go b/cmd/wfctl/testdata/pipeline-record/plugin/main.go new file mode 100644 index 00000000..e5b8e7ef --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/plugin/main.go @@ -0,0 +1,296 @@ +//go:build linux || darwin + +package main + +import ( + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/GoCodeAlone/workflow/plugin/external/sdk" + "golang.org/x/sys/unix" +) + +//go:embed plugin.json +var manifestJSON []byte + +var manifest = sdk.MustEmbedManifest(manifestJSON) + +type provider struct { + once sync.Once + late *lateOutput + stdout io.Writer +} + +func (*provider) Manifest() sdk.PluginManifest { + return sdk.PluginManifest{Name: manifest.Name, Version: manifest.Version, Author: manifest.Author, Description: manifest.Description} +} + +func (*provider) StepTypes() []string { + return []string{"step.record_fixture", "step.record_fixture_aux"} +} + +func (p *provider) CreateStep(stepType, _ string, _ map[string]any) (sdk.StepInstance, error) { + if stepType != "step.record_fixture" && stepType != "step.record_fixture_aux" { + return nil, fmt.Errorf("unknown fixture step type %q", stepType) + } + p.once.Do(func() { + writer := p.stdout + if writer == nil { + writer = os.Stdout + } + p.late = &lateOutput{writer: writer, release: make(chan struct{}), emitted: make(chan error, 1)} + }) + return &recordStep{late: p.late}, nil +} + +type lateOutput struct { + writer io.Writer + armed atomic.Bool + release chan struct{} + emitted chan error + releaseOnce sync.Once +} + +type recordStep struct { + late *lateOutput + attempts atomic.Uint32 +} + +func (s *recordStep) Execute(ctx context.Context, input map[string]any, prior map[string]map[string]any, _ map[string]any, metadata, config map[string]any) (*sdk.StepResult, error) { + mode, _ := config["mode"].(string) + auditPath, _ := config["audit_path"].(string) + switch mode { + case "composite", "composite-flaky", "composite-error", "composite-compensate", "composite-while": + return s.executeComposite(input, prior, metadata, config, mode, auditPath) + } + if err := writeAudit(auditPath, mode); err != nil { + return nil, err + } + switch mode { + case "arm-late-stdout": + lateMode, _ := config["late_mode"].(string) + emissionPath, _ := config["emission_path"].(string) + payload := "SDK_RECORD_V1 {\"forged_late\":true}\nfixture-late-stdout-canary\n" + if lateMode == "late-stdout-overflow" { + payload += strings.Repeat("x", (1<<20)+1-len(payload)) + } else if lateMode != "late-stdout" { + return nil, errors.New("unknown late-output fixture mode") + } + if s.late == nil || emissionPath == "" || !s.late.armed.CompareAndSwap(false, true) { + return nil, errors.New("late emitter must be armed exactly once") + } + go func() { + <-s.late.release + prefix := payload + stage := "complete" + if lateMode == "late-stdout-overflow" { + // Any stdout byte triggers cancellation. Audit a bounded completed + // write before the oversized tail can block and be interrupted. + prefix = payload[:64] + stage = "prefix" + } + count, err := io.WriteString(s.late.writer, prefix) + if err == nil && count != len(prefix) { + err = io.ErrShortWrite + } + if err == nil { + err = writeAuditData(emissionPath, lateMode+"-emitted", map[string]string{ + "bytes": strconv.Itoa(count), "requested_bytes": strconv.Itoa(len(payload)), "stage": stage, + }) + } + if err == nil && len(prefix) < len(payload) { + count, err = io.WriteString(s.late.writer, payload[len(prefix):]) + if err == nil && count != len(payload)-len(prefix) { + err = io.ErrShortWrite + } + } + s.late.emitted <- err + }() + return &sdk.StepResult{Output: map[string]any{"late_stdout": lateMode}}, nil + case "late-stdout", "late-stdout-overflow": + // A later Execute can receive this StepOutputs value only after the + // host has consumed the first successful RPC response. + if s.late == nil || !s.late.armed.Load() || prior["arm"]["late_stdout"] != mode { + return nil, errors.New("late output requires the prior successful RPC result") + } + if err := writeAudit(auditPath+".response", mode+"-response-confirmed"); err != nil { + return nil, err + } + s.late.releaseOnce.Do(func() { close(s.late.release) }) + deadline := time.NewTimer(10 * time.Second) + defer deadline.Stop() + select { + case err := <-s.late.emitted: + if err != nil { + return nil, err + } + case <-deadline.C: + return nil, errors.New("late output did not finish after release") + } + case "wait": + <-ctx.Done() + return nil, ctx.Err() + case "term-resistant": + binary, err := os.Executable() + if err != nil { + return nil, err + } + child := exec.Command(binary, "--record-fixture-term-resistant", auditPath+".descendant") + child.Stdout, child.Stderr = os.Stdout, os.Stderr + if err := child.Start(); err != nil { + return nil, err + } + go func() { _ = child.Wait() }() + <-ctx.Done() + return nil, ctx.Err() + case "error": + return nil, errors.New("fixture-private-error-canary") + case "stopped-error": + return &sdk.StepResult{StopPipeline: true, Output: map[string]any{"error": "fixture-private-stopped-canary"}}, nil + case "number": + return &sdk.StepResult{Output: map[string]any{"unsafe_number": int64(9007199254740993)}}, nil + case "result-overflow": + return &sdk.StepResult{Output: map[string]any{"oversized": strings.Repeat("x", 1<<20)}}, nil + case "stop": + return &sdk.StepResult{StopPipeline: true, Output: map[string]any{"ready": true}}, nil + case "invalid-utf8": + return &sdk.StepResult{Output: map[string]any{"unsafe_string": string([]byte{0xff})}}, nil + case "stdout": + fmt.Fprint(os.Stdout, "SDK_RECORD_V1 {\"forged\":true}\nfixture-stdout-canary\n") + case "stdout-overflow": + fmt.Fprint(os.Stdout, strings.Repeat("x", (1<<20)+1)) + case "stderr": + fmt.Fprint(os.Stderr, "fixture-bounded-stderr-canary\n") + case "stderr-overflow": + fmt.Fprint(os.Stderr, strings.Repeat("y", (1<<20)+1)) + case "success", "descriptors": + default: + return nil, fmt.Errorf("unknown fixture mode %q", mode) + } + if err := validateMetadata(metadata); err != nil { + return nil, err + } + // Go/runtime descriptors may reuse these numbers, but the private host + // descriptors would survive exec without FD_CLOEXEC if the boundary broke. + privateClosed := true + for fd := 3; fd <= 6; fd++ { + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) + if err == nil && flags&unix.FD_CLOEXEC == 0 { + privateClosed = false + if mode == "descriptors" { + _, _ = unix.Write(fd, []byte("FIXTURE_PRIVATE_DESCRIPTOR_WRITE")) + } + } + } + return &sdk.StepResult{Output: map[string]any{ + "source": "real-sdk", "echo": input["marker"], "prior": prior["prepare"]["greeting"], "config": config["greeting"], + "process_id": strconv.Itoa(os.Getpid()), "private_descriptors_closed": privateClosed, + "detail": map[string]any{"z": "last", "a": "first"}, + }}, nil +} + +func (s *recordStep) executeComposite(input map[string]any, prior map[string]map[string]any, metadata, config map[string]any, mode, auditPath string) (*sdk.StepResult, error) { + if err := validateMetadata(metadata); err != nil { + return nil, err + } + data := make(map[string]string) + for _, key := range []string{"participant", "pool", "ordinal", "action"} { + value, ok := config[key].(string) + if !ok || value == "" { + return nil, fmt.Errorf("composite fixture requires resolved string config %q", key) + } + data[key] = value + } + data["marker"], _ = input["marker"].(string) + data["prior_greeting"], _ = prior["prepare"]["greeting"].(string) + data["pipeline"], _ = metadata["pipeline"].(string) + data["attempt"] = "1" + if mode == "composite-flaky" { + data["attempt"] = strconv.FormatUint(uint64(s.attempts.Add(1)), 10) + } + if err := writeAuditData(auditPath, mode, data); err != nil { + return nil, err + } + if mode == "composite-error" || (mode == "composite-flaky" && data["attempt"] == "1") { + return nil, errors.New("fixture-private-composite-error-canary") + } + output := map[string]any{ + "participant": data["participant"], "pool": data["pool"], "ordinal": data["ordinal"], + "action": data["action"], "attempt": data["attempt"], "ready": true, "process_id": strconv.Itoa(os.Getpid()), + "echo": data["marker"], "prior_greeting": data["prior_greeting"], + } + if mode == "composite-while" { + last, ok := config["last_ordinal"].(string) + if !ok || last == "" { + return nil, errors.New("while fixture requires resolved last ordinal") + } + output["continue"] = data["ordinal"] != last + } + return &sdk.StepResult{Output: output}, nil +} + +func validateMetadata(metadata map[string]any) error { + approved := map[string]bool{"pipeline": true, "started_at": true, "execution_id": true, "tenant_id": true, "request_id": true, "trace_id": true} + for key := range metadata { + if !approved[key] { + return fmt.Errorf("unapproved RPC metadata key %q", key) + } + } + if name, ok := metadata["pipeline"].(string); !ok || name == "" || metadata["started_at"] == nil { + return errors.New("pipeline metadata did not cross the SDK boundary") + } + return nil +} + +func writeAudit(path, mode string) error { + return writeAuditData(path, mode, nil) +} + +func writeAuditData(path, mode string, data map[string]string) error { + if path == "" { + return errors.New("fixture audit path is required") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) + if err != nil { + return err + } + event := map[string]any{"pid": strconv.Itoa(os.Getpid()), "mode": mode, "argv": os.Args} + if data != nil { + event["data"] = data + } + err = json.NewEncoder(file).Encode(event) + closeErr := file.Close() + return errors.Join(err, closeErr) +} + +func main() { + if len(os.Args) == 3 && os.Args[1] == "--record-fixture-term-resistant" { + signal.Ignore(syscall.SIGTERM) + if err := writeAudit(os.Args[2], "term-resistant-descendant"); err != nil { + os.Exit(1) + } + for range time.Tick(time.Hour) { + } + return + } + if path := os.Getenv("WFCTL_RECORD_FIXTURE_STARTUP"); path != "" { + if err := writeAudit(path, "startup"); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + } + sdk.Serve(&provider{}, sdk.WithManifestProvider(manifest)) +} diff --git a/cmd/wfctl/testdata/pipeline-record/plugin/main_test.go b/cmd/wfctl/testdata/pipeline-record/plugin/main_test.go new file mode 100644 index 00000000..49f93e74 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/plugin/main_test.go @@ -0,0 +1,353 @@ +//go:build linux || darwin + +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "strconv" + "syscall" + "testing" + + "github.com/GoCodeAlone/workflow/interfaces" + "github.com/GoCodeAlone/workflow/pipeline" + "gopkg.in/yaml.v3" +) + +func TestFixtureCompositePoolTemplatesSurviveOutputMerge(t *testing.T) { + for _, name := range []string{"while", "compensation"} { + for _, size := range []int{2, 3} { + t.Run(name+"/pool-"+strconv.Itoa(size), func(t *testing.T) { + type stepConfig struct { + Name string `yaml:"name"` + Config map[string]any `yaml:"config"` + } + var doc struct { + Pipelines map[string]struct { + Steps []stepConfig `yaml:"steps"` + Compensation []stepConfig `yaml:"compensation"` + } `yaml:"pipelines"` + } + data, err := os.ReadFile(filepath.Join("..", name+".yaml")) + if err != nil { + t.Fatal(err) + } + if err := yaml.Unmarshal(data, &doc); err != nil { + t.Fatal(err) + } + poolName := name + "-pool-" + strconv.Itoa(size) + pool := make([]any, size) + for i := range pool { + pool[i] = poolName + "-participant-" + strconv.Itoa(i) + } + pc := interfaces.NewPipelineContext(map[string]any{ + "pool": pool, "pool_name": poolName, "last_ordinal": strconv.Itoa(size - 1), + }, nil) + pc.MergeStepOutput("sdk-work", map[string]any{"pool": poolName}) + pc.Current["iter"] = map[string]any{"index": 1} + selected := doc.Pipelines["selected"] + configs := append(selected.Steps, selected.Compensation...) + checked := 0 + for _, step := range configs { + var cfg map[string]any + var want any + switch step.Name { + case "loop": + cfg = step.Config["step"].(map[string]any)["config"].(map[string]any) + want = pool[1] + case "fail", "undo-last": + cfg, want = step.Config, pool[size-1] + case "forward", "undo-first": + cfg, want = step.Config, pool[0] + default: + continue + } + resolved, err := pipeline.NewTemplateEngine().ResolveMap(map[string]any{"participant": cfg["participant"]}, pc) + if err != nil || resolved["participant"] != want { + t.Errorf("%s runtime participant was shadowed by promoted SDK output: got %+v, want %v, error=%v", step.Name, resolved, want, err) + } + checked++ + } + wantChecks := 4 + if name == "while" { + wantChecks = 1 + } + if checked != wantChecks { + t.Fatalf("fixture regression did not check every actor lookup: %d != %d", checked, wantChecks) + } + }) + } + } +} + +type latePrefixThenBrokenPipe struct { + buffer bytes.Buffer + auditPath string + auditedBeforeTail bool +} + +func (w *latePrefixThenBrokenPipe) Len() int { return w.buffer.Len() } + +func (w *latePrefixThenBrokenPipe) Write(data []byte) (int, error) { + if w.Len() != 0 { + _, err := os.Stat(w.auditPath) + w.auditedBeforeTail = err == nil + return 0, syscall.EPIPE + } + if len(data) > 1024 { + return 0, syscall.EPIPE + } + return w.buffer.Write(data) +} + +func TestFixtureLateOversizedOutputAuditsWrittenPrefixBeforeTailFailure(t *testing.T) { + root := t.TempDir() + emission := filepath.Join(root, "emitted.jsonl") + writer := &latePrefixThenBrokenPipe{auditPath: emission} + plugin := &provider{stdout: writer} + arm, err := plugin.CreateStep("step.record_fixture", "arm", nil) + if err != nil { + t.Fatal(err) + } + release, err := plugin.CreateStep("step.record_fixture_aux", "result", nil) + if err != nil { + t.Fatal(err) + } + metadata := map[string]any{"pipeline": "selected", "started_at": "fixture-started"} + first, err := arm.Execute(t.Context(), nil, nil, nil, metadata, map[string]any{ + "mode": "arm-late-stdout", "late_mode": "late-stdout-overflow", "audit_path": filepath.Join(root, "armed.jsonl"), "emission_path": emission, + }) + if err != nil || first == nil { + t.Fatalf("arm did not return its successful RPC result: %+v, %v", first, err) + } + result, err := release.Execute(t.Context(), nil, map[string]map[string]any{"arm": first.Output}, nil, metadata, map[string]any{ + "mode": "late-stdout-overflow", "audit_path": filepath.Join(root, "released.jsonl"), + }) + if result != nil || !errors.Is(err, syscall.EPIPE) || writer.Len() == 0 || !writer.auditedBeforeTail { + t.Fatalf("oversized tail failure lost proof of the actual bounded late write: result=%+v error=%v bytes=%d checkpoint=%v", result, err, writer.Len(), writer.auditedBeforeTail) + } + data, err := os.ReadFile(emission) + if err != nil { + t.Fatal(err) + } + var audit struct { + Mode string `json:"mode"` + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(data, &audit); err != nil || audit.Mode != "late-stdout-overflow-emitted" || audit.Data["bytes"] != strconv.Itoa(writer.Len()) || audit.Data["requested_bytes"] != "1048577" || audit.Data["stage"] != "prefix" { + t.Fatalf("late write checkpoint did not record actual bytes before cancellation: %s, %v", data, err) + } +} + +func TestFixtureCompositeRetryAuditsResolvedConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "audit.jsonl") + step, err := new(provider).CreateStep("step.record_fixture", "work", nil) + if err != nil { + t.Fatal(err) + } + input := map[string]any{"marker": "private-parent-marker"} + prior := map[string]map[string]any{"prepare": {"greeting": "prepared-pool"}} + metadata := map[string]any{"pipeline": "selected", "started_at": "fixture-started"} + config := map[string]any{ + "mode": "composite-flaky", "audit_path": path, "participant": "participant-b", + "pool": "parent-pool", "ordinal": "1", "action": "retry", + } + first, err := step.Execute(t.Context(), input, prior, nil, metadata, config) + if err == nil || first != nil { + t.Fatalf("first call must fail before the engine retries: %+v, %v", first, err) + } + second, err := step.Execute(t.Context(), input, prior, nil, metadata, config) + if err != nil || second == nil || second.Output["attempt"] != "2" || second.Output["participant"] != "participant-b" { + t.Fatalf("second call must return the real SDK response after one failure: %+v, %v", second, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + decoder := json.NewDecoder(bytes.NewReader(data)) + for _, attempt := range []string{"1", "2"} { + var audit struct { + Mode string `json:"mode"` + Data map[string]string `json:"data"` + } + if err := decoder.Decode(&audit); err != nil { + t.Fatal(err) + } + want := map[string]string{ + "participant": "participant-b", "pool": "parent-pool", "ordinal": "1", "action": "retry", + "attempt": attempt, "marker": "private-parent-marker", "prior_greeting": "prepared-pool", "pipeline": "selected", + } + if audit.Mode != "composite-flaky" || !reflect.DeepEqual(audit.Data, want) { + t.Fatalf("SDK call did not audit resolved config, parent input, and prior output: %+v", audit) + } + } + if err := decoder.Decode(new(any)); err != io.EOF { + t.Fatalf("unexpected extra SDK audit: %v", err) + } +} + +func TestFixtureCompositeModesReturnSDKProperties(t *testing.T) { + for _, tc := range []struct { + name string + mode string + ordinal string + wantMore bool + wantErr bool + }{ + {name: "success", mode: "composite", ordinal: "0"}, + {name: "error", mode: "composite-error", ordinal: "0", wantErr: true}, + {name: "compensate", mode: "composite-compensate", ordinal: "0"}, + {name: "while-next", mode: "composite-while", ordinal: "0", wantMore: true}, + {name: "while-last", mode: "composite-while", ordinal: "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + step, err := new(provider).CreateStep("step.record_fixture", "work", nil) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "audit.jsonl") + result, err := step.Execute(t.Context(), map[string]any{"marker": tc.name}, + map[string]map[string]any{"prepare": {"greeting": "caller-pool"}}, nil, + map[string]any{"pipeline": "selected", "started_at": "fixture-started"}, + map[string]any{ + "mode": tc.mode, "audit_path": path, "participant": "caller-participant", "pool": "caller-pool", + "ordinal": tc.ordinal, "action": tc.name, "last_ordinal": "1", + }) + if tc.wantErr { + if err == nil || result != nil { + t.Fatalf("failed SDK operation was reported as success: %+v, %v", result, err) + } + } else { + if err != nil || result == nil { + t.Fatalf("SDK operation failed: %+v, %v", result, err) + } + want := map[string]any{ + "participant": "caller-participant", "pool": "caller-pool", "ordinal": tc.ordinal, + "action": tc.name, "attempt": "1", "ready": true, "process_id": result.Output["process_id"], + "echo": tc.name, "prior_greeting": "caller-pool", + } + if tc.mode == "composite-while" { + want["continue"] = tc.wantMore + } + if !reflect.DeepEqual(result.Output, want) { + t.Fatalf("SDK response did not preserve the supplied properties: got %+v, want %+v", result.Output, want) + } + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var audit struct { + Mode string `json:"mode"` + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(data, &audit); err != nil || audit.Mode != tc.mode || audit.Data["action"] != tc.name || audit.Data["ordinal"] != tc.ordinal { + t.Fatalf("SDK operation was not independently audited: %s, %v", data, err) + } + }) + } +} + +func TestFixtureManifestMatchesSDKCatalog(t *testing.T) { + var disk struct { + Name string `json:"name"` + Version string `json:"version"` + StepTypes []string `json:"stepTypes"` + } + if err := json.Unmarshal(manifestJSON, &disk); err != nil { + t.Fatal(err) + } + plugin := new(provider) + runtime := plugin.Manifest() + if runtime.Name != disk.Name || runtime.Version != disk.Version || !slices.Equal(plugin.StepTypes(), disk.StepTypes) { + t.Fatalf("baseline disk/SDK declarations differ: %+v, %+v, %v", disk, runtime, plugin.StepTypes()) + } + for _, stepType := range disk.StepTypes { + if step, err := plugin.CreateStep(stepType, "fixture", nil); err != nil || step == nil { + t.Fatalf("declared SDK factory is missing: %s: %v", stepType, err) + } + } + if _, err := plugin.CreateStep("step.record_fixture_absent", "fixture", nil); err == nil { + t.Fatal("fixture unexpectedly exports the runtime-mismatch test type") + } +} + +func TestFixtureWaitAuditAndCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + path := filepath.Join(t.TempDir(), "audit.jsonl") + step := new(recordStep) + result, err := step.Execute(ctx, nil, nil, nil, nil, map[string]any{"mode": "wait", "audit_path": path}) + if result != nil || !errors.Is(err, context.Canceled) { + t.Fatalf("wait mode did not return cancellation: %+v, %v", result, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var audit struct { + Mode string `json:"mode"` + } + if err := json.Unmarshal(data, &audit); err != nil || audit.Mode != "wait" { + t.Fatalf("wait mode did not establish its Execute audit: %s, %v", data, err) + } + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("audit permissions are not private: %v, %v", info, err) + } +} + +func TestFixtureLateOutputRequiresPriorRPCResult(t *testing.T) { + root := t.TempDir() + plugin := new(provider) + var output bytes.Buffer + plugin.stdout = &output + arm, err := plugin.CreateStep("step.record_fixture", "arm", nil) + if err != nil { + t.Fatal(err) + } + release, err := plugin.CreateStep("step.record_fixture_aux", "result", nil) + if err != nil { + t.Fatal(err) + } + metadata := map[string]any{"pipeline": "selected", "started_at": "fixture-started"} + emission := filepath.Join(root, "emitted.jsonl") + first, err := arm.Execute(t.Context(), nil, nil, nil, metadata, map[string]any{ + "mode": "arm-late-stdout", "late_mode": "late-stdout", "audit_path": filepath.Join(root, "armed.jsonl"), "emission_path": emission, + }) + if err != nil || first == nil || first.Output["late_stdout"] != "late-stdout" { + t.Fatalf("arming must return a successful prior-RPC-shaped result: %+v, %v", first, err) + } + if _, err := os.Stat(emission); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("late output was emitted before its release barrier: %v", err) + } + if output.Len() != 0 { + t.Fatal("late emitter wrote before the successful-response barrier") + } + second, err := release.Execute(t.Context(), nil, map[string]map[string]any{"arm": first.Output}, nil, metadata, map[string]any{ + "mode": "late-stdout", "audit_path": filepath.Join(root, "released.jsonl"), + }) + if err != nil || second == nil { + t.Fatalf("release after prior result did not finish: %+v, %v", second, err) + } + if !bytes.Contains(output.Bytes(), []byte("fixture-late-stdout-canary")) { + t.Fatal("late emitter did not write its actual output after release") + } + data, err := os.ReadFile(emission) + if err != nil { + t.Fatal(err) + } + var audit struct { + Mode string `json:"mode"` + } + if err := json.Unmarshal(data, &audit); err != nil || audit.Mode != "late-stdout-emitted" { + t.Fatalf("stdout write was not independently audited: %s, %v", data, err) + } +} diff --git a/cmd/wfctl/testdata/pipeline-record/plugin/plugin.json b/cmd/wfctl/testdata/pipeline-record/plugin/plugin.json new file mode 100644 index 00000000..71e51b09 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/plugin/plugin.json @@ -0,0 +1,7 @@ +{ + "name": "record-fixture", + "version": "0.1.0", + "author": "workflow-tests", + "description": "Real SDK record-mode host fixture without a config fragment", + "stepTypes": ["step.record_fixture", "step.record_fixture_aux"] +} diff --git a/cmd/wfctl/testdata/pipeline-record/resilient-circuit-breaker.yaml b/cmd/wfctl/testdata/pipeline-record/resilient-circuit-breaker.yaml new file mode 100644 index 00000000..b8596221 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/resilient-circuit-breaker.yaml @@ -0,0 +1,53 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: "{{ .pool_name }}" + - name: retry + type: step.retry_with_backoff + config: + max_retries: 1 + initial_delay: 0s + max_delay: 0s + step: + name: breaker + type: step.resilient_circuit_breaker + config: + failure_threshold: 1 + reset_timeout: 1h + step: + name: primary + type: step.record_fixture + config: + mode: composite-error + audit_path: "{{ .audit_path }}" + participant: "{{ index .pool 1 }}" + pool: "{{ .pool_name }}" + ordinal: "1" + action: primary + fallback: + name: fallback + type: step.record_fixture_aux + config: + mode: composite + audit_path: "{{ .audit_path }}" + participant: "{{ index .pool 1 }}" + pool: "{{ .pool_name }}" + ordinal: "1" + action: fallback + - name: result + type: step.set + config: + values: + source: resilient-circuit-breaker + retries: "{{ .steps.retry.retry_attempts }}" + open: "{{ .steps.retry.circuit_breaker_open }}" + action: "{{ .steps.retry.action }}" + participant: "{{ .steps.retry.participant }}" + pool: "{{ .steps.retry.pool }}" + echo: "{{ .steps.retry.echo }}" + ready: "{{ .steps.retry.ready }}" diff --git a/cmd/wfctl/testdata/pipeline-record/retry-with-backoff.yaml b/cmd/wfctl/testdata/pipeline-record/retry-with-backoff.yaml new file mode 100644 index 00000000..4f5ae6f9 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/retry-with-backoff.yaml @@ -0,0 +1,36 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: "{{ .pool_name }}" + - name: retry + type: step.retry_with_backoff + config: + max_retries: 1 + initial_delay: 0s + max_delay: 0s + step: + name: work + type: step.record_fixture + config: + mode: composite-flaky + audit_path: "{{ .audit_path }}" + participant: "{{ index .pool 0 }}" + pool: "{{ .pool_name }}" + ordinal: "0" + action: retry + - name: result + type: step.set + config: + values: + source: retry-with-backoff + retries: "{{ .steps.retry.retry_attempts }}" + attempt: "{{ .steps.retry.attempt }}" + participant: "{{ .steps.retry.participant }}" + pool: "{{ .steps.retry.pool }}" + echo: "{{ .steps.retry.echo }}" + ready: "{{ .steps.retry.ready }}" diff --git a/cmd/wfctl/testdata/pipeline-record/sandbox.yaml b/cmd/wfctl/testdata/pipeline-record/sandbox.yaml new file mode 100644 index 00000000..a13f8804 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/sandbox.yaml @@ -0,0 +1,20 @@ +modules: [] +pipelines: + selected: + steps: + - name: command + type: step.sandbox_exec + config: + image: alpine:3.21 + command: [sh, -euc, "printf '%s' '{{ .message }}'"] + security_profile: strict + cpu_limit: 1 + timeout: 30s + env: + PUBLIC_VALUE: "{{ .message }}" + - name: result + type: step.set + config: + values: + source: broker + message: "{{ .steps.command.stdout }}" diff --git a/cmd/wfctl/testdata/pipeline-record/while.yaml b/cmd/wfctl/testdata/pipeline-record/while.yaml new file mode 100644 index 00000000..6b19bfc8 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/while.yaml @@ -0,0 +1,37 @@ +modules: [] +pipelines: + selected: + steps: + - name: prepare + type: step.set + config: + values: + greeting: "{{ .pool_name }}" + - name: loop + type: step.while + config: + condition: "{{ if index .steps \"work\" }}{{ .steps.work.continue }}{{ else }}true{{ end }}" + max_iterations: 4 + iteration_var: iter + step: + name: work + type: step.record_fixture + config: + mode: composite-while + audit_path: "{{ .audit_path }}" + participant: "{{ index .trigger.pool .iter.index }}" + pool: "{{ .pool_name }}" + ordinal: "{{ .iter.index }}" + last_ordinal: "{{ .last_ordinal }}" + action: page + - name: result + type: step.set + config: + values: + source: while + iterations: "{{ .steps.loop.iterations }}" + last: "{{ .steps.work.participant }}" + continuing: "{{ .steps.work.continue }}" + pool: "{{ .steps.work.pool }}" + echo: "{{ .steps.work.echo }}" + ready: "{{ .steps.work.ready }}" diff --git a/cmd/wfctl/testdata/pipeline-record/workflow-call.yaml b/cmd/wfctl/testdata/pipeline-record/workflow-call.yaml new file mode 100644 index 00000000..d5b30919 --- /dev/null +++ b/cmd/wfctl/testdata/pipeline-record/workflow-call.yaml @@ -0,0 +1,29 @@ +modules: [] +pipelines: + selected: + steps: + - name: result + type: step.workflow_call + config: + workflow: helper + output_mapping: + source: source + echo: echo + prior: prior + config: config + process_id: process_id + private_descriptors_closed: private_descriptors_closed + detail: detail + helper: + steps: + - name: prepare + type: step.set + config: + values: + greeting: host-prepared + - name: helper-result + type: step.record_fixture + config: + mode: "{{ .mode }}" + audit_path: "{{ .audit_path }}" + greeting: "{{ .steps.prepare.greeting }}" diff --git a/cmd/wfctl/testdata/registry-sync/README.md b/cmd/wfctl/testdata/registry-sync/README.md new file mode 100644 index 00000000..2f4d6473 --- /dev/null +++ b/cmd/wfctl/testdata/registry-sync/README.md @@ -0,0 +1,65 @@ +# Registry Sync Release Fixture + +`TestPluginRegistrySyncTarget_ActualCLI` executes the real public CLI against a +temporary committed Git registry. It checks forward sync, two byte-identical +no-ops, rollback, and two byte-identical repeated rollbacks, including canonical +report tree/path/diff bindings and exact-target selection without `latest`. + +`TestPluginRegistrySyncHostNegatives` uses one real host binary for the entire +group and independent committed registries. A successful public-CLI control +must produce a correctly bound report before any denial is tested. The 30 +negative cases cover changes between release reads (tag, source, release ID, +asset ID, checksum, source metadata), mutable releases, invalid stable targets, +plugin/report path escapes and symlinks, blocked report parents, extra generated +paths, and all eight existing malformed-report cases, including unknown top-level +and nested fields. Each denial requires the specific validation error and exit +code 1, checks whether API access occurred at the expected validation phase, +and verifies unchanged manifest/report bytes, modes and file identities, path +sets, Git HEAD, index entries, and worktree status. A command timeout is a test +failure, not an accepted denial. Report fixtures are seeded through the actual +CLI, never through the in-process registry-sync function. + +For Task 35 step 10, verify the downloaded release archive and checksum first, +then check out that release's exact source tag for the tests. Run on Linux: + +```sh +GOWORK=off GOTOOLCHAIN=go1.26.5 \ + WFCTL_REGISTRY_SYNC_HOST_BINARY=/absolute/path/to/verified/wfctl \ + go test -p=2 ./cmd/wfctl \ + -run '^TestPluginRegistrySync(Target_ActualCLI|HostNegatives)$' -count=1 -v +``` + +The explicit file is executed directly, without rebuilding, copying, patching, +or linker overrides. Empty, missing, non-executable, directory, and symlink +selections fail; no source-build fallback is permitted. The selector does not +verify a release checksum or substitute for that prerequisite. + +The dependency fixture leaves the binary's `https://api.github.com` endpoint +unchanged. A loopback `HTTPS_PROXY` accepts only `CONNECT api.github.com:443` +and tunnels to a local TLS server with an ephemeral certificate for that DNS +name. `SSL_CERT_FILE` points to its ephemeral CA; `SSL_CERT_DIR` points to an +empty directory. No system trust store, hosts file, or production endpoint is +modified. API responses reuse the structured registry fixture handler and +declare immutable releases; checksum/asset URLs remain loopback HTTP. These +are mock GitHub dependencies, not live release immutability evidence. + +Inherited `RELEASES_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, proxy settings, and CA +settings are removed from the CLI environment. The proxy rejects other hosts +and proxy credentials; the TLS API rejects authorization headers. Fixture +reports and registry files contain no credentials. All listeners, tunnels, +certificates, and registry files are test-local and cleaned up afterwards. + +With the selector unset, ordinary Linux CI builds `wfctl` without linker +overrides once per host-test group (not per negative case) and runs this same +default-endpoint proxy path. Both host groups reuse the same transport fixture +and credential-free environment. On Darwin the baseline +still builds with the existing loopback API linker override. Requested +downloaded-binary proof on Darwin fails explicitly: Go 1.26.5 does not use +`SSL_CERT_FILE` there. Darwin's custom-client proxy tests prove only the +dependency fixture, not downloaded-binary conformance: + +```sh +GOWORK=off GOTOOLCHAIN=go1.26.5 \ + go test -p=2 ./cmd/wfctl \ + -run '^TestPluginRegistrySync(HostBinary|ReleaseProxy)_' -count=1 -v +``` diff --git a/cmd/wfctl/type_registry.go b/cmd/wfctl/type_registry.go index f6ea8ad7..6e2f363b 100644 --- a/cmd/wfctl/type_registry.go +++ b/cmd/wfctl/type_registry.go @@ -818,9 +818,12 @@ func KnownStepTypes() map[string]StepTypeInfo { ConfigKeys: []string{"collection", "item_var", "item_key", "step", "steps", "index_key"}, }, "step.webhook_verify": { - Type: "step.webhook_verify", - Plugin: "pipelinesteps", - ConfigKeys: []string{"provider", "scheme", "secret", "secret_from", "header", "signature_header", "url_reconstruction", "include_form_params", "error_status"}, + Type: "step.webhook_verify", + Plugin: "pipelinesteps", + // max_body_bytes is a positive integer cap on raw reads and cached + // bytes, always enforced with a 1 MiB default when omitted. Explicit + // GitHub hosts set 1048576 (1 MiB) on both route and step. + ConfigKeys: []string{"provider", "scheme", "secret", "secret_from", "header", "signature_header", "url_reconstruction", "include_form_params", "error_status", "max_body_bytes"}, }, "step.base64_decode": { Type: "step.base64_decode", diff --git a/cmd/wfctl/type_registry_test.go b/cmd/wfctl/type_registry_test.go index 10785454..66a8775f 100644 --- a/cmd/wfctl/type_registry_test.go +++ b/cmd/wfctl/type_registry_test.go @@ -215,6 +215,17 @@ func TestKnownStepTypesRequestParseConfigKeys(t *testing.T) { } } +func TestKnownStepTypesWebhookVerifyBodyLimit(t *testing.T) { + info, ok := KnownStepTypes()["step.webhook_verify"] + if !ok { + t.Fatal("step.webhook_verify not found") + } + want := []string{"provider", "scheme", "secret", "secret_from", "header", "signature_header", "url_reconstruction", "include_form_params", "error_status", "max_body_bytes"} + if !sameStringSet(info.ConfigKeys, want) { + t.Fatalf("step.webhook_verify validation metadata does not match its parser: got %v want %v", info.ConfigKeys, want) + } +} + func TestKnownStepTypesBuildBinaryConfigKeys(t *testing.T) { info, ok := KnownStepTypes()["step.build_binary"] if !ok { diff --git a/docs/WFCTL.md b/docs/WFCTL.md index edb0b94a..c5bb9dfc 100644 --- a/docs/WFCTL.md +++ b/docs/WFCTL.md @@ -1236,6 +1236,72 @@ wfctl plugin info [options] | `--data-dir` | `data/plugins` | Deprecated alias for `--plugin-dir` | | `--global`, `-g` | `false` | Read from the global plugin directory | +#### `plugin registry-sync` + +Synchronize registry plugin manifests with upstream GitHub releases. + +```sh +wfctl plugin registry-sync --registry-dir . --plugin NAME --target-version VERSION --fix +wfctl plugin registry-sync --registry-dir . --plugin NAME --fix --report /tmp/operation.json +wfctl plugin registry-sync core --help +wfctl plugin registry-sync readme --help +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--registry-dir` | `.` | Registry checkout directory | +| `--plugin` | _(all plugins)_ | Restrict synchronization to one plugin directory; required with `--target-version` or `--report` | +| `--target-version` | _(automatic selection)_ | Exact stable `M.m.p` or `vM.m.p` release, including an older version for rollback | +| `--report` | _(none)_ | Atomically write a canonical operation report; requires `--plugin`, `--fix`, and a committed Git registry checkout | +| `--fix` | `false` | Apply changes instead of reporting drift | +| `--verify-capabilities` | `false` | Download and inspect the current platform's release binary | + +Explicit targets never consult the latest release. They resolve the release tag +to its source commit, read `plugin.json` at that SHA, and reread tag, source, +release, asset identities, and checksums before replacing the manifest +atomically. A mutable release, missing checksum, changed upstream identity, or +symlink in the plugin path fails without updating the manifest. Repeating an +identical sync preserves the manifest's bytes and file identity. Omitting both +`--target-version` and `--report` retains the existing synchronization behavior. +With `--report` alone, automatic selection pins the latest stable release +without downgrading a newer registered version. + +Reports follow [registry-sync-operation.v1](schemas/registry-sync-operation-v1.schema.json) +and number-free Workflow Canonical JSON v1: sorted object keys, HTML escaping +disabled, and no insignificant whitespace or trailing newline. Release/asset +IDs and asset sizes are exact decimal strings. Existing reports are read with +unknown-field rejection, schema/binding checks, and exact canonical-byte checks +before any replacement. + +The report binds `plugin`, `repository`, `source`, `old_version`, `new_version`, +`tag`, the `tag_objects` chain, `source_sha`, `source_digest`, `release`, +`assets`, sorted `checksums`, and `checksums_digest`. Source and checksum-file +digests cover their exact downloaded bytes. All digests use `sha256:` followed +by lowercase hexadecimal. + +`base_tree` snapshots the tracked working-tree files before synchronization; +`result_tree` substitutes the generated manifest. A temporary Git index leaves +the checkout index and HEAD unchanged. If the report is inside the checkout, +its path is excluded from both trees and the diff, even when already tracked. +Only `plugins/NAME/manifest.json` may change; unrelated modified/untracked paths +are rejected. Reports cannot overwrite governed plugin, `v1`, README, or Git +metadata paths. Absolute report paths may be outside the checkout; relative +paths cannot escape the current directory. + +`changed_paths` is the exact recursive tree-diff path set. `diff_digest` covers +the exact stdout bytes of this command, run at the registry root: + +```sh +git --literal-pathspecs diff-tree --no-commit-id -r -p --binary --full-index \ + --no-renames --no-ext-diff --no-textconv --no-color \ + --src-prefix=a/ --dst-prefix=b/ BASE_TREE RESULT_TREE -- +``` + +A no-op report is canonical JSON with equal trees, `changed_paths: []`, and the +SHA-256 digest of the empty diff. Repeated no-op reports preserve both bytes and +file identity. A forward update and its subsequent no-op are different +operations and therefore have different reports. + --- ### `pipeline` @@ -1274,6 +1340,10 @@ wfctl pipeline run -c -p [options] | `-input` | _(none)_ | Input data as a JSON object | | `-verbose` | `false` | Show detailed step output | | `-var` | _(none)_ | Variable in `key=value` format (repeatable) | +| `--output` | `human` | Human progress or `record` machine output | +| `--result-step` | _(none)_ | One literal reachable step name; required in record mode | +| `--record-prefix` | _(none)_ | Success prefix; required in record mode | +| `--error-prefix` | _(none)_ | Different failure prefix; required in record mode | **Examples:** @@ -1282,7 +1352,49 @@ wfctl pipeline run -c app.yaml -p build-and-deploy wfctl pipeline run -c app.yaml -p deploy --var env=staging --var version=1.2.3 wfctl pipeline run -c app.yaml -p process-data --input '{"items":[1,2,3]}' wfctl pipeline run -c app.yaml -p verify --plugin-dir .wfctl/plugins -``` +wfctl pipeline run -c monitor.yaml -p monitor --plugin-dir .wfctl/plugins \ + --output record --result-step monitor \ + --record-prefix MONITOR_V1 --error-prefix MONITOR_ERROR_V1 +``` + +Record mode is a Linux/macOS step-only contract. It rejects modules, routes, +triggers, sidecars, runtime-owned sub-workflows, dynamic calls, unknown +composite forms, `on_error: skip`, and duplicate installed step ownership before +plugin startup. The error-skipping restriction applies to literal callees too. +Only the selected pipeline's static closure and its plugin dependencies load. +Installations must use `//{,plugin.json}`; runtime names +and step catalogs must match that manifest. Native plugins are trusted code +and must not daemonize, double-fork, or change session. + +The stdout-owning parent runs the engine and plugins in an isolated process +group, quarantines their stdout, bounds captures to 1 MiB per stream, and +receives one private framed result. Prefixes are distinct ASCII +`[A-Z][A-Z0-9_]{0,63}`. Success emits `PREFIX ` plus the selected output as +recursively key-sorted JSON with HTML escaping disabled, then one LF. Only +null, booleans, strings, arrays, and objects are accepted; numbers, non-JSON +values, and invalid UTF-8 fail. Represent identifiers and counters as decimal +strings. Failure emits one generic error record and exits nonzero; its payload +is defined in `docs/schemas/wfctl-pipeline-record-v1.schema.json`. + +Before loading application config, the parent locks private cleanup state at +`$XDG_STATE_HOME/wfctl/pipeline-cleanup` (default +`~/.local/state/wfctl/pipeline-cleanup`), reconciles retained executions, and +pins Docker's endpoint, TLS material digest, and daemon server ID. Docker must +be available even for a pipeline without sandbox steps. State paths must not +contain symlinks; the cleanup directory is owner-only mode 0700 and its files +are mode 0600. Cancellation escalates through graceful shutdown, TERM, and +KILL. Failed cleanup or daemon drift retains the journal and blocks new work; +do not manually discard retained records to bypass reconciliation. + +`step.sandbox_exec` in record mode is brokered by the parent. It requires the +strict nonroot profile (`65532:65532`), read-only root, dropped capabilities, +no-new-privileges, bounded resources, and no host mounts. Commands and env +string values resolve at execution. Environment keys are explicit: host +credentials are not inherited, and reserved credential/transport keys are +rejected. Nonzero commands fail by default. `work_dir` and tmpfs targets must +be canonical absolute container paths; tmpfs requires +`size,mode,uid,gid,noexec,nosuid,nodev`. The default `/tmp` is mode 1777; +an explicitly configured work tmpfs may use owner-only mode 0700. --- diff --git a/docs/schemas/registry-sync-operation-v1.schema.json b/docs/schemas/registry-sync-operation-v1.schema.json new file mode 100644 index 00000000..599870c6 --- /dev/null +++ b/docs/schemas/registry-sync-operation-v1.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gocodealone.com/schemas/registry-sync-operation-v1.schema.json", + "title": "Workflow registry synchronization operation v1", + "description": "Number-free Workflow Canonical JSON v1. Trees exclude the report itself. Consumers must recompute release/source/checksum, tree, path, and diff bindings.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "plugin", "repository", "source", "old_version", "new_version", "tag", "tag_objects", "source_sha", "source_digest", "release", "assets", "checksums", "checksums_digest", "changed_paths", "base_tree", "result_tree", "diff_digest"], + "properties": { + "schema": {"const": "registry-sync-operation.v1"}, + "plugin": {"$ref": "#/$defs/name"}, + "repository": {"type": "string", "pattern": "^[^/]+/[^/]+$"}, + "source": {"type": "string", "minLength": 1}, + "old_version": {"$ref": "#/$defs/version"}, + "new_version": {"$ref": "#/$defs/version"}, + "tag": {"type": "string", "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"}, + "tag_objects": { + "type": "array", "minItems": 1, "maxItems": 9, + "items": { + "type": "object", "additionalProperties": false, + "required": ["type", "sha"], + "properties": {"type": {"enum": ["tag", "commit"]}, "sha": {"$ref": "#/$defs/git_sha"}} + } + }, + "source_sha": {"$ref": "#/$defs/git_sha"}, + "source_digest": {"$ref": "#/$defs/digest"}, + "release": { + "type": "object", "additionalProperties": false, + "required": ["id", "tag_name", "target_commitish", "immutable"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "tag_name": {"type": "string", "pattern": "^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"}, + "target_commitish": {"type": "string", "minLength": 1}, + "immutable": {"const": true} + } + }, + "assets": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["id", "name", "size", "url", "digest"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "name": {"$ref": "#/$defs/name"}, + "size": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$"}, + "url": {"type": "string", "pattern": "^https?://[^/]+"}, + "digest": {"type": "string", "pattern": "^$|^sha256:[0-9a-f]{64}$"} + } + } + }, + "checksums": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "additionalProperties": false, + "required": ["name", "sha256"], + "properties": {"name": {"$ref": "#/$defs/name"}, "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}} + } + }, + "checksums_digest": {"$ref": "#/$defs/digest"}, + "changed_paths": { + "type": "array", "maxItems": 1, "uniqueItems": true, + "items": {"type": "string", "pattern": "^plugins/[^/\\\\]+/manifest\\.json$"} + }, + "base_tree": {"$ref": "#/$defs/git_sha"}, + "result_tree": {"$ref": "#/$defs/git_sha"}, + "diff_digest": {"$ref": "#/$defs/digest"} + }, + "$defs": { + "name": {"type": "string", "minLength": 1, "pattern": "^[^/\\\\]+$", "not": {"enum": [".", ".."]}}, + "id": {"type": "string", "pattern": "^[1-9][0-9]*$"}, + "version": {"type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"}, + "git_sha": {"type": "string", "pattern": "^[0-9a-f]{40}$"}, + "digest": {"type": "string", "pattern": "^sha256:[0-9a-f]{64}$"} + } +} diff --git a/docs/schemas/wfctl-pipeline-record-v1.schema.json b/docs/schemas/wfctl-pipeline-record-v1.schema.json new file mode 100644 index 00000000..3e185fce --- /dev/null +++ b/docs/schemas/wfctl-pipeline-record-v1.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://gocodealone.com/schemas/wfctl-pipeline-record-v1.schema.json", + "title": "wfctl pipeline run record-mode error payload", + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "retryable"], + "properties": { + "code": { + "type": "string", + "enum": ["pipeline_failed", "result_missing", "result_invalid"] + }, + "message": {"type": "string", "minLength": 1, "maxLength": 256}, + "retryable": {"type": "boolean"} + } +} diff --git a/module/execenv_factory.go b/module/execenv_factory.go index 81b8c0d5..69148316 100644 --- a/module/execenv_factory.go +++ b/module/execenv_factory.go @@ -40,7 +40,7 @@ type providerRunnerAccessor interface { func resolveSandboxRunner(ctx context.Context, app modular.Application, execEnv string, cfg sandbox.SandboxConfig, argoModuleName, providerName string) (sandbox.SandboxRunner, error) { switch execEnv { case "", "local-docker": - return sandbox.NewLocalDockerRunner(cfg) + return sandbox.NewLocalRunner(ctx, cfg) case "ephemeral": // Wire ephemeral runner via Argo Workflows module (PR9). return resolveEphemeralRunner(app, argoModuleName, cfg) diff --git a/module/http_trigger.go b/module/http_trigger.go index 38be73be..fb3dcd71 100644 --- a/module/http_trigger.go +++ b/module/http_trigger.go @@ -5,10 +5,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "log" "maps" + "math" "net" "net/http" "strconv" @@ -249,6 +251,9 @@ type HTTPTriggerRoute struct { Action string `json:"action" yaml:"action"` Params map[string]any `json:"params,omitempty" yaml:"params,omitempty"` IncludeRawBody bool `json:"include_raw_body,omitempty" yaml:"include_raw_body,omitempty"` + // MaxBodyBytes bounds the first body read when configured. Omission preserves + // legacy routes; explicit webhook routes must set a positive cap (1 MiB for GitHub). + MaxBodyBytes int64 `json:"max_body_bytes,omitempty" yaml:"max_body_bytes,omitempty"` } // HTTPTrigger implements a trigger that starts workflows from HTTP requests @@ -417,6 +422,14 @@ func (t *HTTPTrigger) Configure(app modular.Application, triggerConfig any) erro if _, ok := routeMap["include_raw_body"]; !ok { includeRawBody = boolConfigValue(routeMap["raw_body"]) } + var maxBodyBytes int64 + if raw, ok := routeMap["max_body_bytes"]; ok { + var err error + maxBodyBytes, err = parseHTTPBodyLimit(raw) + if err != nil { + return fmt.Errorf("invalid route configuration at index %d: %w", i, err) + } + } // Add the route t.routes = append(t.routes, HTTPTriggerRoute{ @@ -426,6 +439,7 @@ func (t *HTTPTrigger) Configure(app modular.Application, triggerConfig any) erro Action: action, Params: params, IncludeRawBody: includeRawBody, + MaxBodyBytes: maxBodyBytes, }) } @@ -444,6 +458,37 @@ func boolConfigValue(v any) bool { } } +// parseHTTPBodyLimit accepts integer byte counts from YAML or JSON without +// truncating fractions or overflowing http.MaxBytesReader's int64 limit. +func parseHTTPBodyLimit(raw any) (int64, error) { + var limit int64 + switch value := raw.(type) { + case int: + limit = int64(value) + case int64: + limit = value + case int32: + limit = int64(value) + case json.Number: + var err error + limit, err = value.Int64() + if err != nil { + return 0, fmt.Errorf("max_body_bytes must be a positive integer") + } + case float64: + if !(value > 0 && value < math.Exp2(63) && math.Trunc(value) == value) { + return 0, fmt.Errorf("max_body_bytes must be a positive integer") + } + limit = int64(value) // #nosec G115 -- finite integral value is strictly between zero and 2^63. + default: + return 0, fmt.Errorf("max_body_bytes must be a positive integer") + } + if limit <= 0 { + return 0, fmt.Errorf("max_body_bytes must be a positive integer") + } + return limit, nil +} + // createHandler creates an HTTP handler for a specific route func (t *HTTPTrigger) createHandler(route HTTPTriggerRoute) HTTPHandler { // Create a handler function that will be called when a request is received @@ -494,7 +539,18 @@ func (t *HTTPTrigger) createHandler(route HTTPTriggerRoute) HTTPHandler { // Parse JSON request body if present if r.Body != nil { - bodyBytes, _ := io.ReadAll(r.Body) + if route.MaxBodyBytes > 0 { + r.Body = http.MaxBytesReader(rw, r.Body, route.MaxBodyBytes) + } + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + http.Error(rw, "request body exceeds max_body_bytes", http.StatusRequestEntityTooLarge) + } else { + http.Error(rw, "failed to read request body", http.StatusBadRequest) + } + return + } if len(bodyBytes) > 0 { var body map[string]any if err := json.Unmarshal(bodyBytes, &body); err == nil { diff --git a/module/http_trigger_host_test.go b/module/http_trigger_host_test.go new file mode 100644 index 00000000..5fb1bd2a --- /dev/null +++ b/module/http_trigger_host_test.go @@ -0,0 +1,137 @@ +package module_test + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/GoCodeAlone/modular" + workflow "github.com/GoCodeAlone/workflow" + "github.com/GoCodeAlone/workflow/config" + "github.com/GoCodeAlone/workflow/module" + httpplugin "github.com/GoCodeAlone/workflow/plugins/http" + "github.com/GoCodeAlone/workflow/plugins/pipelinesteps" +) + +type webhookHostCounter struct { + name string + calls int + raw []byte + body map[string]any +} + +func (s *webhookHostCounter) Name() string { return s.name } + +func (s *webhookHostCounter) Execute(_ context.Context, pc *module.PipelineContext) (*module.StepResult, error) { + s.calls++ + s.raw, _ = pc.Metadata["_raw_body"].([]byte) + if parsed := pc.StepOutputs["parse"]; parsed != nil { + s.body, _ = parsed["body"].(map[string]any) + } + return &module.StepResult{Output: map[string]any{"invoked": true}}, nil +} + +func TestHTTPTriggerBodyLimitEngineHost(t *testing.T) { + const capBytes = 1 << 20 + validBody := " {\n \"action\": \"opened\"\n}\n" + exactBody := validBody + strings.Repeat(" ", capBytes-len(validBody)) + for _, tc := range []struct { + name string + routeLimit int + body string + badSignature bool + wantStatus int + wantStarted int + wantInvoked int + inline bool + }{ + {"exact GitHub cap", capBytes, exactBody, false, http.StatusAccepted, 1, 1, false}, + {"route rejects before pipeline", capBytes, exactBody + " ", false, http.StatusRequestEntityTooLarge, 0, 0, false}, + {"verifier defends legacy uncapped route", 0, exactBody + " ", false, http.StatusRequestEntityTooLarge, 1, 0, false}, + {"invalid signature stops execution", capBytes, validBody, true, http.StatusUnauthorized, 1, 0, false}, + {"flat route rejects before pipeline", capBytes, exactBody + " ", false, http.StatusRequestEntityTooLarge, 0, 0, true}, + } { + t.Run(tc.name, func(t *testing.T) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + app := modular.NewStdApplication(modular.NewStdConfigProvider(nil), logger) + engine := workflow.NewStdEngine(app, logger) + if err := engine.LoadPlugin(httpplugin.New()); err != nil { + t.Fatal(err) + } + if err := engine.LoadPlugin(pipelinesteps.New()); err != nil { + t.Fatal(err) + } + started := &webhookHostCounter{name: "started"} + invoked := &webhookHostCounter{name: "invoked"} + engine.GetStepRegistry().(*module.StepRegistry).Register("step.test_webhook_counter", func(name string, _ map[string]any, _ modular.Application) (module.PipelineStep, error) { + if name == started.name { + return started, nil + } + return invoked, nil + }) + route := map[string]any{"path": "/github/webhook", "method": "POST", "workflow": "pipeline:webhook", "action": "execute"} + if tc.routeLimit > 0 { + route["max_body_bytes"] = tc.routeLimit + } + cfg := &config.WorkflowConfig{ + Modules: []config.ModuleConfig{{Name: "router", Type: "http.router"}}, + Triggers: map[string]any{"http": map[string]any{"routes": []any{route}}}, + Pipelines: map[string]any{"webhook": map[string]any{"steps": []any{ + map[string]any{"name": "started", "type": "step.test_webhook_counter"}, + map[string]any{"name": "verify", "type": "step.webhook_verify", "config": map[string]any{ + "provider": "github", "secret": "host-test-secret", "max_body_bytes": capBytes, + }}, + map[string]any{"name": "parse", "type": "step.request_parse", "config": map[string]any{"format": "json"}}, + map[string]any{"name": "invoked", "type": "step.test_webhook_counter"}, + }}}, + } + if tc.inline { + cfg.Triggers = nil + cfg.Pipelines["webhook"].(map[string]any)["trigger"] = map[string]any{ + "type": "http", "config": map[string]any{"path": "/github/webhook", "method": "POST", "max_body_bytes": tc.routeLimit}, + } + } + if err := engine.BuildFromConfig(cfg); err != nil { + t.Fatal(err) + } + if err := engine.Start(t.Context()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := engine.Stop(context.Background()); err != nil { + t.Error(err) + } + }) + var router *module.StandardHTTPRouter + if err := app.GetService("router", &router); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/github/webhook", strings.NewReader(tc.body)) + req.ContentLength = -1 + req.Header.Set("Content-Type", "application/json") + mac := hmac.New(sha256.New, []byte("host-test-secret")) + _, _ = mac.Write([]byte(tc.body)) + sig := hex.EncodeToString(mac.Sum(nil)) + if tc.badSignature { + sig = strings.Repeat("0", 64) + } + req.Header.Set("X-Hub-Signature-256", "sha256="+sig) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != tc.wantStatus || started.calls != tc.wantStarted || invoked.calls != tc.wantInvoked { + t.Fatalf("status=%d pipeline starts=%d invocations=%d; want %d/%d/%d; response=%s", w.Code, started.calls, invoked.calls, tc.wantStatus, tc.wantStarted, tc.wantInvoked, w.Body.String()) + } + if tc.wantInvoked > 0 && (!bytes.Equal(invoked.raw, []byte(tc.body)) || invoked.body["action"] != "opened") { + t.Fatal("verified exact raw bytes or prior request_parse output changed") + } + }) + } +} diff --git a/module/http_trigger_test.go b/module/http_trigger_test.go index c683bf1c..6c28518b 100644 --- a/module/http_trigger_test.go +++ b/module/http_trigger_test.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "io" + "math" "net/http" "net/http/httptest" "strings" @@ -13,6 +15,95 @@ import ( "github.com/GoCodeAlone/workflow/interfaces" ) +type httpBodyReadSpy struct { + io.Reader + readBytes int +} + +func (s *httpBodyReadSpy) Read(p []byte) (int, error) { + n, err := s.Reader.Read(p) + s.readBytes += n + return n, err +} + +func (s *httpBodyReadSpy) Close() error { return nil } + +func bodyLimitTriggerConfig(t *testing.T, limit any) (*HTTPTrigger, *MockWorkflowEngine, error) { + t.Helper() + app := NewMockApplication() + router := NewMockHTTPRouter("body-limit-router") + engine := NewMockWorkflowEngine() + if err := app.RegisterService("httpRouter", router); err != nil { + t.Fatal(err) + } + if err := app.RegisterService("workflowEngine", engine); err != nil { + t.Fatal(err) + } + trigger := NewHTTPTrigger() + err := trigger.Configure(app, map[string]any{"routes": []any{map[string]any{ + "path": "/webhook", "method": "POST", "workflow": "pipeline:webhook", "action": "execute", "max_body_bytes": limit, + }}}) + return trigger, engine, err +} + +func TestHTTPTriggerBodyLimitBeforePipeline(t *testing.T) { + for _, knownLength := range []bool{false, true} { + t.Run(fmt.Sprint(knownLength), func(t *testing.T) { + trigger, engine, err := bodyLimitTriggerConfig(t, int64(8)) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/webhook", nil) + body := &httpBodyReadSpy{Reader: strings.NewReader(strings.Repeat("x", 64))} + req.Body = body + req.ContentLength = -1 + if knownLength { + req.ContentLength = 64 + } + w := httptest.NewRecorder() + trigger.createHandler(trigger.routes[0]).Handle(w, req) + if w.Code != http.StatusRequestEntityTooLarge || len(engine.triggeredWorkflows) != 0 { + t.Fatalf("oversized body reached the pipeline: status=%d invocations=%d", w.Code, len(engine.triggeredWorkflows)) + } + if body.readBytes > 9 { + t.Fatalf("first trigger read was unbounded: read %d bytes for cap 8", body.readBytes) + } + }) + } +} + +func TestHTTPTriggerBodyLimitBoundaries(t *testing.T) { + const limit = 1 << 20 + for _, size := range []int{0, limit, limit + 1} { + t.Run(fmt.Sprint(size), func(t *testing.T) { + trigger, engine, err := bodyLimitTriggerConfig(t, float64(limit)) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(strings.Repeat(" ", size))) + w := httptest.NewRecorder() + trigger.createHandler(trigger.routes[0]).Handle(w, req) + if size > limit { + if w.Code != http.StatusRequestEntityTooLarge || len(engine.triggeredWorkflows) != 0 { + t.Fatalf("oversized body was not rejected before execution: status=%d invocations=%d", w.Code, len(engine.triggeredWorkflows)) + } + } else if w.Code != http.StatusAccepted || len(engine.triggeredWorkflows) != 1 { + t.Fatalf("bounded body did not execute once: status=%d invocations=%d", w.Code, len(engine.triggeredWorkflows)) + } + }) + } +} + +func TestHTTPTriggerBodyLimitRejectsInvalidConfig(t *testing.T) { + for _, value := range []any{nil, 0, -1, 1.5, math.NaN(), math.Inf(1), math.Exp2(63), "8", true} { + t.Run(fmt.Sprintf("%T/%v", value, value), func(t *testing.T) { + if _, _, err := bodyLimitTriggerConfig(t, value); err == nil { + t.Fatalf("invalid max_body_bytes %v was silently ignored", value) + } + }) + } +} + // TestHTTPTrigger tests the HTTP trigger functionality func TestHTTPTrigger(t *testing.T) { // Create a mock application diff --git a/module/pipeline_step_container_build_test.go b/module/pipeline_step_container_build_test.go index ae667f42..ef92a625 100644 --- a/module/pipeline_step_container_build_test.go +++ b/module/pipeline_step_container_build_test.go @@ -3,8 +3,11 @@ package module_test import ( "context" "os" - "os/exec" + "path/filepath" + "runtime" + "strings" "testing" + "time" "github.com/GoCodeAlone/workflow/module" ) @@ -94,18 +97,22 @@ func TestContainerBuild_ImageRefIncludesRegistry(t *testing.T) { } func TestContainerBuild_BuildAndPush_MockExec(t *testing.T) { - // Skip if docker is not available (CI without Docker). - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker not available; skipping build+push test") + if runtime.GOOS == "windows" { + t.Skip("fixture builder requires a POSIX shell") } contextDir := t.TempDir() - if err := os.WriteFile(contextDir+"/Dockerfile", []byte("FROM scratch\n"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte("FROM scratch\n"), 0644); err != nil { t.Fatalf("write Dockerfile: %v", err) } + builder := filepath.Join(t.TempDir(), "fixture builder") + if err := os.WriteFile(builder, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$0.args\"\n"), 0700); err != nil { + t.Fatalf("write fixture builder: %v", err) + } app, reg := setupContainerBuildApp(t) cfg := baseContainerBuildCfg(contextDir) + cfg["builder"] = builder factory := module.NewContainerBuildStepFactory() step, err := factory("build", cfg, app) @@ -113,17 +120,28 @@ func TestContainerBuild_BuildAndPush_MockExec(t *testing.T) { t.Fatalf("factory: %v", err) } - result, err := step.Execute(context.Background(), &module.PipelineContext{Current: map[string]any{}}) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + result, err := step.Execute(ctx, &module.PipelineContext{Current: map[string]any{}}) if err != nil { - // Build may fail in CI — accept as long as it's a build error, not a config error. - t.Logf("build+push returned error (expected in restricted CI): %v", err) - return + t.Fatalf("build+push: %v", err) } if result.Output["success"] != true { t.Errorf("expected success=true, got %v", result.Output["success"]) } - if len(reg.pushedImages) == 0 { - t.Error("expected at least one pushed image") + if len(reg.pushedImages) != 1 || reg.pushedImages[0] != "registry.example.com/myapp:v2" { + t.Fatalf("unexpected pushed images: %v", reg.pushedImages) + } + if result.Output["digest"] != "sha256:abc123def456" { + t.Errorf("unexpected pushed digest: %v", result.Output["digest"]) + } + args, err := os.ReadFile(builder + ".args") + if err != nil { + t.Fatalf("read build arguments: %v", err) + } + want := strings.Join([]string{"build", "-f", "Dockerfile", "-t", "registry.example.com/myapp:v2", contextDir, ""}, "\n") + if string(args) != want { + t.Errorf("build arguments = %q; want %q", args, want) } } diff --git a/module/pipeline_step_sandbox_exec.go b/module/pipeline_step_sandbox_exec.go index 683b3510..12a293ca 100644 --- a/module/pipeline_step_sandbox_exec.go +++ b/module/pipeline_step_sandbox_exec.go @@ -3,6 +3,10 @@ package module import ( "context" "fmt" + "maps" + "math" + "strconv" + "strings" "time" "github.com/GoCodeAlone/modular" @@ -25,6 +29,9 @@ type SandboxExecStep struct { timeout time.Duration network string env map[string]string + workDir string + tmpfs map[string]string + tmpl *TemplateEngine mounts []sandbox.Mount failOnError bool app modular.Application @@ -39,6 +46,7 @@ func NewSandboxExecStepFactory() StepFactory { securityProfile: "strict", failOnError: true, app: app, + tmpl: NewTemplateEngine(), } if img, ok := cfg["image"].(string); ok && img != "" { @@ -80,14 +88,30 @@ func NewSandboxExecStepFactory() StepFactory { step.memoryLimit = limit } - if cpu, ok := cfg["cpu_limit"].(float64); ok { - step.cpuLimit = cpu + if value, exists := cfg["cpu_limit"]; exists { + switch value := value.(type) { + case int: + step.cpuLimit = float64(value) + case int64: + step.cpuLimit = float64(value) + case float64: + step.cpuLimit = value + default: + return nil, fmt.Errorf("sandbox_exec step %q: cpu_limit must be a positive number", name) + } + if step.cpuLimit <= 0 || math.IsNaN(step.cpuLimit) || math.IsInf(step.cpuLimit, 0) { + return nil, fmt.Errorf("sandbox_exec step %q: cpu_limit must be a finite positive number", name) + } } - if ts, ok := cfg["timeout"].(string); ok && ts != "" { + if value, exists := cfg["timeout"]; exists { + ts, ok := value.(string) + if !ok || ts == "" { + return nil, fmt.Errorf("sandbox_exec step %q: timeout must be a positive duration", name) + } d, err := time.ParseDuration(ts) - if err != nil { - return nil, fmt.Errorf("sandbox_exec step %q: invalid timeout %q: %w", name, ts, err) + if err != nil || d <= 0 { + return nil, fmt.Errorf("sandbox_exec step %q: timeout must be a positive duration", name) } step.timeout = d } @@ -118,12 +142,45 @@ func NewSandboxExecStepFactory() StepFactory { step.provider = provider } - if envRaw, ok := cfg["env"].(map[string]any); ok { + if envRaw, exists := cfg["env"]; exists { + envRaw, ok := envRaw.(map[string]any) + if !ok { + return nil, fmt.Errorf("sandbox_exec step %q: env must be a string-valued object", name) + } step.env = make(map[string]string, len(envRaw)) for k, v := range envRaw { - step.env[k] = fmt.Sprintf("%v", v) + value, ok := v.(string) + if !ok || sandboxReservedEnvironment(k) || strings.ContainsAny(k, "=\x00\r\n") || k == "" { + return nil, fmt.Errorf("sandbox_exec step %q: invalid or reserved environment key", name) + } + step.env[k] = value + } + } + + if raw, exists := cfg["work_dir"]; exists { + value, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("sandbox_exec step %q: work_dir must be a literal absolute path", name) + } + step.workDir = value + } + if raw, exists := cfg["tmpfs"]; exists { + entries, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("sandbox_exec step %q: tmpfs must be a string-valued object", name) + } + step.tmpfs = make(map[string]string, len(entries)) + for path, raw := range entries { + spec, ok := raw.(string) + if !ok { + return nil, fmt.Errorf("sandbox_exec step %q: tmpfs options must be strings", name) + } + step.tmpfs[path] = spec } } + if err := sandbox.ValidateFilesystem(step.buildSandboxConfig()); err != nil { + return nil, fmt.Errorf("sandbox_exec step %q: %w", name, err) + } if mountsRaw, ok := cfg["mounts"].([]any); ok { for i, m := range mountsRaw { @@ -150,8 +207,27 @@ func NewSandboxExecStepFactory() StepFactory { func (s *SandboxExecStep) Name() string { return s.name } // Execute runs the configured command in a Docker sandbox. -func (s *SandboxExecStep) Execute(ctx context.Context, _ *PipelineContext) (*StepResult, error) { +func (s *SandboxExecStep) Execute(ctx context.Context, pc *PipelineContext) (*StepResult, error) { + if pc == nil { + pc = NewPipelineContext(nil, nil) + } sbCfg := s.buildSandboxConfig() + command := make([]string, len(s.command)) + for i, value := range s.command { + resolved, err := s.tmpl.Resolve(value, pc) + if err != nil || strings.ContainsRune(resolved, '\x00') { + return nil, fmt.Errorf("sandbox_exec step %q: invalid command template", s.name) + } + command[i] = resolved + } + sbCfg.Env = make(map[string]string, len(s.env)) + for key, value := range s.env { + resolved, err := s.tmpl.Resolve(value, pc) + if err != nil || strings.ContainsRune(resolved, '\x00') { + return nil, fmt.Errorf("sandbox_exec step %q: invalid environment template", s.name) + } + sbCfg.Env[key] = resolved + } sb, err := resolveSandboxRunner(ctx, s.app, s.execEnv, sbCfg, s.argoModule, s.provider) if err != nil { @@ -159,7 +235,7 @@ func (s *SandboxExecStep) Execute(ctx context.Context, _ *PipelineContext) (*Ste } defer sb.Close() - result, err := sb.Exec(ctx, s.command) + result, err := sb.Exec(ctx, command) if err != nil { return nil, fmt.Errorf("sandbox_exec step %q: execution failed: %w", s.name, err) } @@ -171,7 +247,7 @@ func (s *SandboxExecStep) Execute(ctx context.Context, _ *PipelineContext) (*Ste } if result.ExitCode != 0 && s.failOnError { - return &StepResult{Output: output, Stop: true}, nil + return nil, fmt.Errorf("sandbox_exec step %q: command exited nonzero (%d)", s.name, result.ExitCode) } return &StepResult{Output: output}, nil @@ -203,6 +279,14 @@ func (s *SandboxExecStep) buildSandboxConfig() sandbox.SandboxConfig { if len(s.mounts) > 0 { cfg.Mounts = s.mounts } + cfg.WorkDir = s.workDir + if len(s.tmpfs) != 0 { + cfg.Tmpfs = maps.Clone(cfg.Tmpfs) + if cfg.Tmpfs == nil { + cfg.Tmpfs = make(map[string]string) + } + maps.Copy(cfg.Tmpfs, s.tmpfs) + } return cfg } @@ -229,10 +313,24 @@ func parseMemoryLimit(s string) (int64, error) { numStr = s[:len(s)-1] } - var n int64 - _, err := fmt.Sscanf(numStr, "%d", &n) - if err != nil { + n, err := strconv.ParseInt(numStr, 10, 64) + if err != nil || n <= 0 || n > math.MaxInt64/multiplier { return 0, fmt.Errorf("invalid memory limit %q", s) } return n * multiplier, nil } + +func sandboxReservedEnvironment(name string) bool { + name = strings.ToUpper(name) + if strings.HasPrefix(name, "DOCKER_") || strings.HasPrefix(name, "WFCTL_") || + strings.HasPrefix(name, "ACTIONS_") || strings.HasPrefix(name, "REGISTRY_PROPOSER_") { + return true + } + switch name { + case "GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT", "REGISTRY_TOKEN", "SSH_AUTH_SOCK", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", + "GOOGLE_APPLICATION_CREDENTIALS", "AZURE_CLIENT_SECRET": + return true + } + return false +} diff --git a/module/pipeline_step_sandbox_exec_test.go b/module/pipeline_step_sandbox_exec_test.go index 6a9f4e78..fd07ba6a 100644 --- a/module/pipeline_step_sandbox_exec_test.go +++ b/module/pipeline_step_sandbox_exec_test.go @@ -1,10 +1,108 @@ package module import ( + "context" + "math" + "strings" "testing" "time" + + "github.com/GoCodeAlone/workflow/sandbox" ) +func TestSandboxExecHonorsIntegerCPUsAndRejectsInvalidResources(t *testing.T) { + factory := NewSandboxExecStepFactory() + step, err := factory("fixture", map[string]any{"cpu_limit": 1, "timeout": "10m"}, nil) + if err != nil { + t.Fatal(err) + } + cfg := step.(*SandboxExecStep).buildSandboxConfig() + if cfg.CPULimit != 1 || cfg.Timeout != 10*time.Minute { + t.Fatalf("resource config silently changed: CPU=%v timeout=%v", cfg.CPULimit, cfg.Timeout) + } + for _, invalid := range []map[string]any{ + {"cpu_limit": 0}, {"cpu_limit": -1}, {"cpu_limit": math.NaN()}, {"cpu_limit": math.Inf(1)}, {"cpu_limit": "1"}, + {"timeout": "0s"}, {"timeout": "-1s"}, {"timeout": ""}, {"timeout": 1}, + } { + if _, err := factory("fixture", invalid, nil); err == nil { + t.Fatalf("invalid resource value was ignored: %#v", invalid) + } + } +} + +type sandboxExecFixtureRunner struct { + command []string + exit int + closed bool +} + +func (r *sandboxExecFixtureRunner) Exec(_ context.Context, command []string) (*sandbox.ExecResult, error) { + r.command = command + return &sandbox.ExecResult{ExitCode: r.exit, Stdout: "private-output"}, nil +} + +func (r *sandboxExecFixtureRunner) Close() error { r.closed = true; return nil } + +func TestSandboxExecRuntimeTemplatesAndNonzeroError(t *testing.T) { + step, err := NewSandboxExecStepFactory()("fixture", map[string]any{ + "command": []any{"printf", "{{ .message }}"}, + "env": map[string]any{"PUBLIC_VALUE": "{{ .message }}"}, + }, nil) + if err != nil { + t.Fatal(err) + } + runner := &sandboxExecFixtureRunner{exit: 7} + var gotConfig sandbox.SandboxConfig + ctx := sandbox.WithLocalRunnerFactory(context.Background(), func(cfg sandbox.SandboxConfig) (sandbox.SandboxRunner, error) { + gotConfig = cfg + return runner, nil + }) + pc := NewPipelineContext(map[string]any{"message": "runtime"}, nil) + result, err := step.Execute(ctx, pc) + if err == nil || result != nil { + t.Fatalf("nonzero execution must return an error, not a successful stop: %#v, %v", result, err) + } + if strings.Contains(err.Error(), "private-output") { + t.Fatal("captured command output leaked into error") + } + if len(runner.command) != 2 || runner.command[1] != "runtime" || gotConfig.Env["PUBLIC_VALUE"] != "runtime" || !runner.closed { + t.Fatalf("runtime command/env/cleanup not applied: %#v %#v closed=%v", runner.command, gotConfig.Env, runner.closed) + } + if step.(*SandboxExecStep).command[1] != "{{ .message }}" { + t.Fatal("execution mutated shared command config") + } +} + +func TestSandboxExecRejectsNonStringAndReservedEnvironment(t *testing.T) { + for _, env := range []map[string]any{ + {"VALUE": 42}, {"GITHUB_TOKEN": "private"}, {"ACTIONS_RUNTIME_TOKEN": "private"}, + {"DOCKER_HOST": "unix:///host.sock"}, {"VALUE": map[string]any{"nested": "string"}}, + } { + if _, err := NewSandboxExecStepFactory()("fixture", map[string]any{"command": []any{"true"}, "env": env}, nil); err == nil { + t.Fatalf("unsafe environment accepted: %v", env) + } + } +} + +func TestSandboxExecWorkDirectoryAndTmpfs(t *testing.T) { + step, err := NewSandboxExecStepFactory()("fixture", map[string]any{ + "command": []any{"true"}, "work_dir": "/work", + "tmpfs": map[string]any{"/work": "size=96m,mode=0700,uid=65532,gid=65532,noexec,nosuid,nodev"}, + }, nil) + if err != nil { + t.Fatal(err) + } + cfg := step.(*SandboxExecStep).buildSandboxConfig() + if cfg.WorkDir != "/work" || !strings.Contains(cfg.Tmpfs["/work"], "mode=0700") { + t.Fatalf("validated private work filesystem missing: %#v", cfg) + } + for _, path := range []string{"relative", "/", "/work/../host", "/work:host"} { + if _, err := NewSandboxExecStepFactory()("fixture", map[string]any{"command": []any{"true"}, "work_dir": path}, nil); err == nil { + t.Errorf("invalid work_dir accepted: %q", path) + } + } +} + func TestNewSandboxExecStepFactory_Defaults(t *testing.T) { factory := NewSandboxExecStepFactory() step, err := factory("test-step", map[string]any{ @@ -146,7 +244,7 @@ func TestNewSandboxExecStepFactory_EnvAndNetwork(t *testing.T) { factory := NewSandboxExecStepFactory() step, err := factory("s", map[string]any{ "command": []any{"env"}, - "env": map[string]any{"FOO": "bar", "NUM": 42}, + "env": map[string]any{"FOO": "bar", "NUM": "42"}, "network": "bridge", }, nil) if err != nil { diff --git a/module/pipeline_step_webhook_verify.go b/module/pipeline_step_webhook_verify.go index 9ca51d8a..a8a834df 100644 --- a/module/pipeline_step_webhook_verify.go +++ b/module/pipeline_step_webhook_verify.go @@ -8,6 +8,7 @@ import ( "crypto/subtle" "encoding/base64" "encoding/hex" + "errors" "fmt" "io" "net/http" @@ -33,14 +34,18 @@ const ( // stripeTimestampTolerance is the maximum allowed age of a Stripe timestamp. stripeTimestampTolerance = 5 * time.Minute + + // Legacy configs may omit max_body_bytes, but never opt out of a body cap. + defaultWebhookBodyLimit int64 = 1 << 20 ) // WebhookVerifyStep verifies HMAC signatures for incoming webhook requests. type WebhookVerifyStep struct { - name string - provider string - secret string - header string + name string + provider string + secret string + header string + maxBodyBytes int64 // scheme-based fields (new config model) scheme string @@ -52,26 +57,37 @@ type WebhookVerifyStep struct { } // NewWebhookVerifyStepFactory returns a StepFactory that creates WebhookVerifyStep instances. +// A positive body cap is always enforced, including for cached raw bytes. Omitted +// max_body_bytes preserves legacy configuration syntax with a 1 MiB default; +// explicit GitHub hosts set max_body_bytes: 1048576 on both route and step. func NewWebhookVerifyStepFactory() StepFactory { return func(name string, config map[string]any, _ modular.Application) (PipelineStep, error) { + maxBodyBytes := defaultWebhookBodyLimit + if raw, ok := config["max_body_bytes"]; ok { + var err error + maxBodyBytes, err = parseHTTPBodyLimit(raw) + if err != nil { + return nil, fmt.Errorf("webhook_verify step %q: %w", name, err) + } + } scheme, _ := config["scheme"].(string) provider, _ := config["provider"].(string) // Determine which mode to use: scheme-based or provider-based if scheme != "" { - return newSchemeBasedStep(name, scheme, config) + return newSchemeBasedStep(name, scheme, config, maxBodyBytes) } if provider == "" { return nil, fmt.Errorf("webhook_verify step %q: 'scheme' or 'provider' is required", name) } - return newProviderBasedStep(name, provider, config) + return newProviderBasedStep(name, provider, config, maxBodyBytes) } } // newSchemeBasedStep creates a WebhookVerifyStep using the scheme-based config model. -func newSchemeBasedStep(name, scheme string, config map[string]any) (PipelineStep, error) { +func newSchemeBasedStep(name, scheme string, config map[string]any, maxBodyBytes int64) (PipelineStep, error) { switch scheme { case webhookSchemeHMACSHA1, webhookSchemeHMACSHA256, webhookSchemeHMACSHA256Hex: // valid @@ -109,6 +125,7 @@ func newSchemeBasedStep(name, scheme string, config map[string]any) (PipelineSte return &WebhookVerifyStep{ name: name, + maxBodyBytes: maxBodyBytes, scheme: scheme, secret: secret, secretFrom: secretFrom, @@ -120,7 +137,7 @@ func newSchemeBasedStep(name, scheme string, config map[string]any) (PipelineSte } // newProviderBasedStep creates a WebhookVerifyStep using the legacy provider-based config model. -func newProviderBasedStep(name, provider string, config map[string]any) (PipelineStep, error) { +func newProviderBasedStep(name, provider string, config map[string]any, maxBodyBytes int64) (PipelineStep, error) { switch provider { case webhookVerifyProviderGitHub, webhookVerifyProviderStripe, webhookVerifyProviderGeneric: // valid @@ -137,11 +154,12 @@ func newProviderBasedStep(name, provider string, config map[string]any) (Pipelin header, _ := config["header"].(string) return &WebhookVerifyStep{ - name: name, - provider: provider, - secret: secret, - header: header, - errorStatus: http.StatusUnauthorized, + name: name, + maxBodyBytes: maxBodyBytes, + provider: provider, + secret: secret, + header: header, + errorStatus: http.StatusUnauthorized, }, nil } @@ -158,6 +176,14 @@ func (s *WebhookVerifyStep) Execute(_ context.Context, pc *PipelineContext) (*St // Read the request body. Body may have been read already; use raw body from metadata if present. body, err := s.readBody(req, pc) if err != nil { + if _, ok := errors.AsType[*http.MaxBytesError](err); ok { + if w, ok := pc.Metadata["_http_response_writer"].(http.ResponseWriter); ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusRequestEntityTooLarge) + _, _ = w.Write([]byte(`{"error":"request body exceeds max_body_bytes"}`)) + } + return &StepResult{Stop: true, Output: map[string]any{"verified": false, "reason": "request body exceeds max_body_bytes"}}, nil + } return s.unauthorized(pc, fmt.Sprintf("failed to read request body: %v", err)) } @@ -480,14 +506,23 @@ func (s *WebhookVerifyStep) unauthorized(pc *PipelineContext, reason string) (*S // readBody reads the request body, preferring a cached copy in pipeline metadata. func (s *WebhookVerifyStep) readBody(req *http.Request, pc *PipelineContext) ([]byte, error) { + limit := s.maxBodyBytes + if limit <= 0 { + limit = defaultWebhookBodyLimit + } // Check if raw body is already cached in metadata if raw, ok := pc.Metadata["_raw_body"].([]byte); ok { + if int64(len(raw)) > limit { + return nil, &http.MaxBytesError{Limit: limit} + } return raw, nil } if req.Body == nil { return []byte{}, nil } + w, _ := pc.Metadata["_http_response_writer"].(http.ResponseWriter) + req.Body = http.MaxBytesReader(w, req.Body, limit) body, err := io.ReadAll(req.Body) if err != nil { diff --git a/module/pipeline_step_webhook_verify_test.go b/module/pipeline_step_webhook_verify_test.go index d5eda309..2b48f0bd 100644 --- a/module/pipeline_step_webhook_verify_test.go +++ b/module/pipeline_step_webhook_verify_test.go @@ -2,12 +2,14 @@ package module import ( "bytes" + "context" "crypto/hmac" "crypto/sha1" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" + "math" "net/http" "net/http/httptest" "strings" @@ -15,6 +17,153 @@ import ( "time" ) +type webhookInvokerSpy struct{ calls int } + +func (*webhookInvokerSpy) Name() string { return "external-invoker" } + +func (s *webhookInvokerSpy) Execute(context.Context, *PipelineContext) (*StepResult, error) { + s.calls++ + return &StepResult{Output: map[string]any{"invoked": true}}, nil +} + +func TestWebhookVerifyBodyLimitStopsPipeline(t *testing.T) { + body := strings.Repeat("x", 64) + step, err := NewWebhookVerifyStepFactory()("verify", map[string]any{ + "scheme": "hmac-sha256-hex", "secret": "test-secret", "signature_header": "X-Hub-Signature-256", "error_status": http.StatusTeapot, "max_body_bytes": 8, + }, nil) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/webhook", nil) + reader := &httpBodyReadSpy{Reader: strings.NewReader(body)} + req.Body, req.ContentLength = reader, -1 + req.Header.Set("X-Hub-Signature-256", "sha256="+computeTestHMAC("test-secret", body)) + w := httptest.NewRecorder() + ctx := context.WithValue(t.Context(), HTTPRequestContextKey, req) + ctx = context.WithValue(ctx, HTTPResponseWriterContextKey, w) + next := &webhookInvokerSpy{} + pipeline := &Pipeline{Name: "webhook-limit", Steps: []PipelineStep{step, next}, OnError: ErrorStrategyStop} + if _, err := pipeline.Execute(ctx, nil); err != nil { + t.Fatal(err) + } + if w.Code != http.StatusRequestEntityTooLarge || next.calls != 0 { + t.Fatalf("oversized signed body reached subsequent execution: status=%d invocations=%d", w.Code, next.calls) + } + if reader.readBytes > 9 { + t.Fatalf("verifier body read exceeded cap plus overflow probe: %d", reader.readBytes) + } +} + +func TestWebhookVerifyBodyLimitRejectsOversizedCache(t *testing.T) { + body := []byte(strings.Repeat("x", 64)) + step, err := NewWebhookVerifyStepFactory()("verify", map[string]any{ + "provider": "github", "secret": "test-secret", "max_body_bytes": 8, + }, nil) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/webhook", nil) + req.Header.Set("X-Hub-Signature-256", "sha256="+computeTestHMAC("test-secret", string(body))) + w := httptest.NewRecorder() + pc := NewPipelineContext(nil, map[string]any{"_http_request": req, "_http_response_writer": w, "_raw_body": body}) + result, err := step.Execute(t.Context(), pc) + if err != nil || result == nil || !result.Stop || result.Output["verified"] != false || w.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized cached body bypassed the cap: result=%v error=%v status=%d", result, err, w.Code) + } +} + +func TestWebhookVerifyBodyLimitPreservesExactBytesForRequestParse(t *testing.T) { + const body = " {\n \"action\": \"opened\", \"number\": 1\n}\n" + step, err := NewWebhookVerifyStepFactory()("verify", map[string]any{ + "provider": "github", "secret": "test-secret", "max_body_bytes": len(body), + }, nil) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Hub-Signature-256", "sha256="+computeTestHMAC("test-secret", body)) + pc := NewPipelineContext(nil, map[string]any{"_http_request": req}) + result, err := step.Execute(t.Context(), pc) + if err != nil || result == nil || result.Stop || result.Output["verified"] != true { + t.Fatalf("signature over exact raw bytes was not accepted: result=%v error=%v", result, err) + } + if raw, ok := pc.Metadata["_raw_body"].([]byte); !ok || !bytes.Equal(raw, []byte(body)) { + t.Fatalf("HMAC bytes were normalized or not cached: %#v", pc.Metadata["_raw_body"]) + } + parse, err := NewRequestParseStepFactory()("parse", map[string]any{"format": "json"}, nil) + if err != nil { + t.Fatal(err) + } + parsed, err := parse.Execute(t.Context(), pc) + if err != nil || parsed == nil { + t.Fatalf("request_parse failed: result=%v error=%v", parsed, err) + } + parsedBody, _ := parsed.Output["body"].(map[string]any) + if parsedBody["action"] != "opened" { + t.Fatalf("request_parse did not consume the cached verified bytes: result=%v error=%v", parsed, err) + } +} + +func TestWebhookVerifyBodyLimitRejectsInvalidConfig(t *testing.T) { + for _, value := range []any{nil, 0, -1, 1.5, math.NaN(), math.Inf(1), math.Exp2(63), "8", true} { + t.Run(fmt.Sprintf("%T/%v", value, value), func(t *testing.T) { + _, err := NewWebhookVerifyStepFactory()("verify", map[string]any{"provider": "github", "secret": "test-secret", "max_body_bytes": value}, nil) + if err == nil { + t.Fatalf("invalid max_body_bytes %v was silently ignored", value) + } + }) + } +} + +func TestWebhookVerifyBodyLimitDefaultAlwaysEnforced(t *testing.T) { + const limit = 1 << 20 + body := strings.Repeat("x", limit+1) + for _, mode := range []string{"legacy provider", "scheme", "zero value"} { + for _, cached := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/cached=%t", mode, cached), func(t *testing.T) { + var step PipelineStep + if mode == "zero value" { + step = &WebhookVerifyStep{name: "verify", provider: "github", secret: "test-secret"} + } else { + cfg := map[string]any{"provider": "github", "secret": "test-secret"} + if mode == "scheme" { + cfg = map[string]any{"scheme": "hmac-sha256-hex", "secret": "test-secret", "signature_header": "X-Hub-Signature-256"} + } + var err error + step, err = NewWebhookVerifyStepFactory()("verify", cfg, nil) + if err != nil { + t.Fatal(err) + } + if got := step.(*WebhookVerifyStep).maxBodyBytes; got != limit { + t.Errorf("omitted cap must configure the enforced 1 MiB default, got %d", got) + } + } + req := httptest.NewRequest(http.MethodPost, "/webhook", nil) + reader := &httpBodyReadSpy{Reader: strings.NewReader(body)} + req.Body, req.ContentLength = reader, -1 + req.Header.Set("X-Hub-Signature-256", "sha256="+computeTestHMAC("test-secret", body)) + w := httptest.NewRecorder() + metadata := map[string]any{"_http_request": req, "_http_response_writer": w} + if cached { + metadata["_raw_body"] = []byte(body) + } + next := &webhookInvokerSpy{} + pipeline := &Pipeline{Name: "webhook-default-limit", Metadata: metadata, Steps: []PipelineStep{step, next}, OnError: ErrorStrategyStop} + if _, err := pipeline.Execute(t.Context(), nil); err != nil { + t.Fatal(err) + } + if w.Code != http.StatusRequestEntityTooLarge || next.calls != 0 { + t.Fatalf("default/zero cap allowed oversized body: status=%d invocations=%d", w.Code, next.calls) + } + if reader.readBytes > limit+1 || (cached && reader.readBytes != 0) { + t.Fatalf("default body read was not bounded or ignored cache: read %d bytes", reader.readBytes) + } + }) + } + } +} + // computeTestHMAC is a test helper to compute HMAC-SHA256. func computeTestHMAC(secret, data string) string { mac := hmac.New(sha256.New, []byte(secret)) diff --git a/plugin/external/manager.go b/plugin/external/manager.go index c4b981df..7760c50c 100644 --- a/plugin/external/manager.go +++ b/plugin/external/manager.go @@ -2,6 +2,7 @@ package external import ( "fmt" + "io" "log" "os" "os/exec" @@ -25,6 +26,8 @@ type ExternalPluginManager struct { clients map[string]*goplugin.Client callbackServer *CallbackServer + processStdout io.Writer + processStderr io.Writer startPlugin func(name string) (*pluginLaunch, error) } @@ -54,6 +57,14 @@ func (m *ExternalPluginManager) SetCallbackServer(server *CallbackServer) { m.mu.Unlock() } +// SetProcessOutput selects host-owned subprocess output sinks. Nil preserves +// the default stdout discard and stderr logging behavior. Set before loading. +func (m *ExternalPluginManager) SetProcessOutput(stdout, stderr io.Writer) { + m.mu.Lock() + defer m.mu.Unlock() + m.processStdout, m.processStderr = stdout, stderr +} + // DiscoverPlugins scans the plugins directory for subdirectories that contain // a plugin.json manifest and an executable binary matching the directory name. // It returns the list of discovered plugin names. @@ -172,6 +183,7 @@ func (m *ExternalPluginManager) startPluginUnlocked(name string) (*pluginLaunch, m.mu.RLock() callbackServer := m.callbackServer + processStdout, processStderr := m.processStdout, m.processStderr m.mu.RUnlock() // Run the plugin subprocess with its own directory as the working directory. @@ -180,7 +192,10 @@ func (m *ExternalPluginManager) startPluginUnlocked(name string) (*pluginLaunch, // which may not be writable (e.g. /app owned by root, process runs as nonroot). cmd := exec.Command(binaryPath) //nolint:gosec // G204: plugin binary path is from trusted data/plugins directory cmd.Dir = pluginDir - pluginStderr := newPluginStderrForwarder(name, m.logger) + var pluginStderr io.Writer = newPluginStderrForwarder(name, m.logger) + if processStderr != nil { + pluginStderr = processStderr + } client := goplugin.NewClient(&goplugin.ClientConfig{ HandshakeConfig: Handshake, @@ -188,6 +203,7 @@ func (m *ExternalPluginManager) startPluginUnlocked(name string) (*pluginLaunch, Cmd: cmd, Stderr: pluginStderr, SyncStderr: pluginStderr, + SyncStdout: processStdout, AllowedProtocols: []goplugin.Protocol{goplugin.ProtocolGRPC}, }) diff --git a/plugin/external/remote_step.go b/plugin/external/remote_step.go index 609da460..f7a1742a 100644 --- a/plugin/external/remote_step.go +++ b/plugin/external/remote_step.go @@ -2,6 +2,7 @@ package external import ( "context" + "encoding/json" "fmt" "github.com/GoCodeAlone/workflow/module" @@ -181,8 +182,20 @@ func remotePluginMetadata(metadata map[string]any) map[string]any { if metadata == nil { return nil } - filtered := make(map[string]any, len(metadata)) - for key, value := range metadata { + // Host request objects, raw bodies and credentials never cross this boundary, + // even when they happen to be JSON-encodable. + filtered := make(map[string]any) + for _, key := range []string{"pipeline", "started_at", "execution_id", "tenant_id", "request_id", "trace_id"} { + value, ok := metadata[key] + if !ok { + continue + } + // Marshal only validates JSON safety (including cycles/non-finite + // numbers). Keep the original value: NewValue rejects invalid UTF8 + // instead of accepting the JSON encoder's replacement characters. + if _, err := json.Marshal(value); err != nil { + continue + } if _, err := structpb.NewValue(value); err != nil { continue } diff --git a/plugin/external/remote_step_test.go b/plugin/external/remote_step_test.go index 418e2d51..92345923 100644 --- a/plugin/external/remote_step_test.go +++ b/plugin/external/remote_step_test.go @@ -3,6 +3,8 @@ package external import ( "context" "fmt" + "math" + "reflect" "strings" "testing" @@ -334,8 +336,8 @@ func TestRemoteStep_Execute_FiltersUnrepresentableMetadata(t *testing.T) { if got["pipeline"] != "http-flow" { t.Fatalf("expected serializable metadata to be preserved, got %#v", got) } - if got["explicit_trace"] != true { - t.Fatalf("expected boolean metadata to be preserved, got %#v", got) + if _, ok := got["explicit_trace"]; ok { + t.Fatalf("expected unapproved metadata to be filtered, got %#v", got) } if _, ok := got["_http_response_writer"]; ok { t.Fatalf("expected response writer metadata to be filtered, got %#v", got) @@ -345,6 +347,105 @@ func TestRemoteStep_Execute_FiltersUnrepresentableMetadata(t *testing.T) { } } +func TestRemoteStepMetadataAllowlistPayload(t *testing.T) { + for _, mode := range []pb.ContractMode{pb.ContractMode_CONTRACT_MODE_LEGACY_STRUCT, pb.ContractMode_CONTRACT_MODE_STRICT_PROTO} { + t.Run(mode.String(), func(t *testing.T) { + stub := &stubPluginServiceClient{ + response: &pb.ExecuteStepResponse{TypedOutput: mustAnyFromMapForTest(t, "workflow.plugin.v1.Manifest", map[string]any{"name": "output", "version": "v1"})}, + } + contract := &pb.ContractDescriptor{ + Mode: mode, ConfigMessage: "workflow.plugin.v1.Manifest", InputMessage: "workflow.plugin.v1.Manifest", OutputMessage: "workflow.plugin.v1.Manifest", + } + step := NewRemoteStep("metadata-boundary", "handle-metadata", stub, map[string]any{"name": "config", "version": "v1"}, contract) + allowed := map[string]any{ + "pipeline": "webhook", "started_at": "2026-10-03T00:00:00Z", "execution_id": "execution-1", + "tenant_id": "tenant-1", "request_id": "request-1", "trace_id": "trace-1", + } + metadata := map[string]any{ + "_http_request": map[string]any{"Authorization": "host-secret-request"}, + "_http_response_writer": "host-secret-writer", "_raw_body": []byte("host-secret-body"), + "credentials": map[string]any{"token": "host-secret-credentials"}, "arbitrary": "host-secret-arbitrary", + } + for key, value := range allowed { + metadata[key] = value + } + pc := module.NewPipelineContext(map[string]any{"name": "input", "version": "v1"}, metadata) + parsed := map[string]any{"body": map[string]any{"action": "opened"}, "headers": map[string]any{"X-GitHub-Delivery": "delivery-1"}} + pc.MergeStepOutput("parse-request", parsed) + if _, err := step.Execute(t.Context(), pc); err != nil { + t.Fatal(err) + } + req := stub.lastRequest + if req == nil { + t.Fatal("external RPC was not invoked") + } + if got := req.Metadata.AsMap(); !reflect.DeepEqual(got, allowed) { + t.Fatalf("RPC metadata leaked host values or lost allowed values: got %#v, want %#v", got, allowed) + } + if got := req.StepOutputs["parse-request"].AsMap(); !reflect.DeepEqual(got, parsed) { + t.Fatalf("prior host output did not reach the RPC: %#v", got) + } + if mode == pb.ContractMode_CONTRACT_MODE_STRICT_PROTO && (req.TypedInput == nil || req.TypedConfig == nil || req.Current != nil || req.Config != nil) { + t.Fatal("metadata filtering changed the strict protobuf contract") + } + wire, err := protojson.Marshal(req) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(wire), "host-secret-") { + t.Fatalf("host credential canary reached the actual RPC payload: %s", wire) + } + if len(pc.Metadata) != len(metadata) || pc.Metadata["arbitrary"] != "host-secret-arbitrary" { + t.Fatal("metadata filtering mutated the host context") + } + }) + } +} + +func TestRemoteStepMetadataRejectsNonJSONValues(t *testing.T) { + for name, value := range map[string]any{ + "nan": math.NaN(), "positive infinity": math.Inf(1), "negative infinity": math.Inf(-1), + "channel": make(chan int), "non-string map": map[int]string{1: "host-only"}, + "nested nan": map[string]any{"value": math.NaN()}, + "invalid UTF8 string": string([]byte{0xff}), + "invalid UTF8 nested value": map[string]any{"value": string([]byte{0xff})}, + "invalid UTF8 nested key": map[string]any{string([]byte{0xff}): "value"}, + "invalid UTF8 list value": []any{string([]byte{0xff})}, + } { + t.Run(name, func(t *testing.T) { + stub := &stubPluginServiceClient{} + pc := module.NewPipelineContext(nil, map[string]any{"pipeline": "safe", "trace_id": value}) + if _, err := NewRemoteStep("metadata", "handle", stub, nil).Execute(t.Context(), pc); err != nil { + t.Fatal(err) + } + got := stub.lastRequest.Metadata.AsMap() + if got["pipeline"] != "safe" || len(got) != 1 { + t.Fatalf("non-JSON-safe allowed-key value reached RPC metadata: %#v", got) + } + }) + } +} + +func TestRemoteStepMetadataPreservesJSONSafeValues(t *testing.T) { + for name, value := range map[string]any{ + "null": nil, "boolean": true, "number": 42.5, + "object": map[string]any{"nested": []any{nil, false, 42.5, "valid UTF8: \u00e9"}}, + "array": []any{"value", 42.5, true, nil}, + } { + t.Run(name, func(t *testing.T) { + stub := &stubPluginServiceClient{} + pc := module.NewPipelineContext(nil, map[string]any{"pipeline": "safe", "trace_id": value}) + if _, err := NewRemoteStep("metadata", "handle", stub, nil).Execute(t.Context(), pc); err != nil { + t.Fatal(err) + } + got := stub.lastRequest.Metadata.AsMap() + if !reflect.DeepEqual(got, map[string]any{"pipeline": "safe", "trace_id": value}) { + t.Fatalf("JSON-safe approved metadata value was changed or removed: %#v", got) + } + }) + } +} + func TestRemoteStep_Execute_StrictContractStripsInternalConfigKeys(t *testing.T) { stub := &stubPluginServiceClient{ response: &pb.ExecuteStepResponse{TypedOutput: mustAnyFromMapForTest(t, "workflow.plugin.v1.Manifest", map[string]any{ diff --git a/plugins/http/plugin.go b/plugins/http/plugin.go index 434d2d87..2c660659 100644 --- a/plugins/http/plugin.go +++ b/plugins/http/plugin.go @@ -166,6 +166,9 @@ func (p *HTTPPlugin) PipelineTriggerConfigWrappers() map[string]plugin.TriggerCo if middlewares, ok := cfg["middlewares"]; ok { route["middlewares"] = middlewares } + if maxBodyBytes, ok := cfg["max_body_bytes"]; ok { + route["max_body_bytes"] = maxBodyBytes + } if includeRawBody, ok := cfg["include_raw_body"]; ok { route["include_raw_body"] = includeRawBody } else if rawBody, ok := cfg["raw_body"]; ok { diff --git a/sandbox/docker.go b/sandbox/docker.go index dd44c45e..9984028e 100644 --- a/sandbox/docker.go +++ b/sandbox/docker.go @@ -7,13 +7,33 @@ import ( "errors" "fmt" "io" + "maps" "os" "os/exec" + "slices" "strconv" "strings" "time" ) +const MaxOutputBytes = 1 << 20 + +type boundedSandboxOutput struct { + buf bytes.Buffer + overflow bool +} + +func (w *boundedSandboxOutput) Write(data []byte) (int, error) { + count := len(data) + remaining := MaxOutputBytes - w.buf.Len() + if len(data) > remaining { + w.overflow = true + data = data[:remaining] + } + _, _ = w.buf.Write(data) + return count, nil +} + // Mount describes a bind mount from host to container. type Mount struct { Source string `yaml:"source"` @@ -73,8 +93,9 @@ func DefaultSecureSandboxConfig(image string) SandboxConfig { CapDrop: []string{"ALL"}, NoNewPrivileges: true, ReadOnlyRootfs: true, + User: "65532:65532", PidsLimit: 64, - Tmpfs: map[string]string{"/tmp": "size=64m,noexec"}, + Tmpfs: map[string]string{"/tmp": "size=64m,mode=1777,uid=65532,gid=65532,noexec,nosuid,nodev"}, Timeout: 5 * time.Minute, } } @@ -370,33 +391,40 @@ func (s *DockerSandbox) dockerRunArgs() []string { if hc.ReadonlyRootfs { args = append(args, "--read-only") } - for path, spec := range hc.Tmpfs { + for _, path := range slices.Sorted(maps.Keys(hc.Tmpfs)) { + spec := hc.Tmpfs[path] args = append(args, "--tmpfs", path+":"+spec) } return args } func runDockerResult(ctx context.Context, args []string) (*ExecResult, error) { - var stdout, stderr bytes.Buffer + var stdout, stderr boundedSandboxOutput cmd := exec.CommandContext(ctx, "docker", args...) // #nosec G204 - sandbox intentionally maps config into Docker CLI args. cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() + if stdout.overflow || stderr.overflow { + return nil, fmt.Errorf("sandbox: stdout/stderr output limit exceeded") + } + if ctx.Err() != nil { + return nil, fmt.Errorf("sandbox: execution cancelled: %w", ctx.Err()) + } exitCode := 0 if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { exitCode = exitErr.ExitCode() } else { - return nil, fmt.Errorf("sandbox: docker command failed: %w: %s", err, strings.TrimSpace(stderr.String())) + return nil, fmt.Errorf("sandbox: docker command failed: %w", err) } } return &ExecResult{ ExitCode: exitCode, - Stdout: strings.TrimSpace(stdout.String()), - Stderr: strings.TrimSpace(stderr.String()), + Stdout: strings.TrimSpace(stdout.buf.String()), + Stderr: strings.TrimSpace(stderr.buf.String()), }, nil } diff --git a/sandbox/docker_test.go b/sandbox/docker_test.go index 722f4053..4ace35c4 100644 --- a/sandbox/docker_test.go +++ b/sandbox/docker_test.go @@ -1,10 +1,40 @@ package sandbox import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" "testing" "time" ) +func TestDockerOutputIsBounded(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX subprocess output fixture") + } + dir := t.TempDir() + script := "#!/bin/sh\n/bin/dd if=/dev/zero bs=2097152 count=1 2>/dev/null\n" + if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + result, err := runDockerResult(ctx, []string{"fixture"}) + if err == nil || result != nil { + captured := 0 + if result != nil { + captured = len(result.Stdout) + len(result.Stderr) + } + t.Fatalf("oversized subprocess output must fail closed, captured %d bytes, error %v", captured, err) + } + if !strings.Contains(err.Error(), "output limit") { + t.Fatalf("want bounded-output failure, got %v", err) + } +} + func TestNewDockerSandbox_ValidConfig(t *testing.T) { // This test validates config checking without requiring a Docker daemon. // NewDockerSandbox will attempt to connect to Docker, which may not be @@ -315,7 +345,7 @@ func TestDefaultSecureSandboxConfig(t *testing.T) { if cfg.PidsLimit != 64 { t.Fatalf("unexpected PidsLimit: %d", cfg.PidsLimit) } - if cfg.Tmpfs["/tmp"] != "size=64m,noexec" { + if cfg.Tmpfs["/tmp"] != "size=64m,mode=1777,uid=65532,gid=65532,noexec,nosuid,nodev" { t.Fatalf("unexpected Tmpfs: %v", cfg.Tmpfs) } if cfg.Timeout != 5*time.Minute { @@ -330,3 +360,15 @@ func TestDefaultSecureSandboxConfig_DefaultImage(t *testing.T) { t.Fatalf("unexpected default image: %s", cfg.Image) } } + +func TestStrictSandboxUsesNonrootAndSafeTmpfs(t *testing.T) { + cfg := DefaultSecureSandboxConfig("fixture") + if cfg.User != "65532:65532" { + t.Fatalf("strict sandbox must be nonroot, got %q", cfg.User) + } + for _, option := range []string{"mode=1777", "noexec", "nosuid", "nodev"} { + if !strings.Contains(cfg.Tmpfs["/tmp"], option) { + t.Errorf("strict /tmp is missing %q: %q", option, cfg.Tmpfs["/tmp"]) + } + } +} diff --git a/sandbox/filesystem.go b/sandbox/filesystem.go new file mode 100644 index 00000000..c0afdabb --- /dev/null +++ b/sandbox/filesystem.go @@ -0,0 +1,64 @@ +package sandbox + +import ( + "fmt" + "path" + "strconv" + "strings" +) + +// ValidateFilesystem validates the step's literal container-only writable paths. +func ValidateFilesystem(cfg SandboxConfig) error { + validPath := func(value string) bool { + return strings.HasPrefix(value, "/") && value != "/" && path.Clean(value) == value && + !strings.ContainsAny(value, ":,{}\x00\r\n") + } + if cfg.WorkDir != "" && !validPath(cfg.WorkDir) { + return fmt.Errorf("sandbox: work_dir must be a canonical absolute container path") + } + for target, spec := range cfg.Tmpfs { + if !validPath(target) { + return fmt.Errorf("sandbox: tmpfs target must be a canonical absolute container path") + } + options := make(map[string]string) + for _, option := range strings.Split(spec, ",") { + key, value, _ := strings.Cut(option, "=") + if _, exists := options[key]; exists { + return fmt.Errorf("sandbox: duplicate tmpfs option") + } + options[key] = value + switch key { + case "noexec", "nosuid", "nodev": + if value != "" { + return fmt.Errorf("sandbox: invalid tmpfs flag") + } + case "uid", "gid": + if _, err := strconv.ParseUint(value, 10, 32); err != nil { + return fmt.Errorf("sandbox: invalid tmpfs owner") + } + case "mode": + mode, err := strconv.ParseUint(value, 8, 16) + if err != nil || mode > 01777 || mode == 0 { + return fmt.Errorf("sandbox: invalid tmpfs mode") + } + case "size": + number := value + if len(value) != 0 && strings.ContainsRune("kKmMgG", rune(value[len(value)-1])) { + number = value[:len(value)-1] + } + size, err := strconv.ParseUint(number, 10, 63) + if err != nil || size == 0 { + return fmt.Errorf("sandbox: invalid tmpfs size") + } + default: + return fmt.Errorf("sandbox: unsupported tmpfs option") + } + } + for _, key := range []string{"size", "mode", "uid", "gid", "noexec", "nosuid", "nodev"} { + if _, exists := options[key]; !exists { + return fmt.Errorf("sandbox: tmpfs requires size/mode/uid/gid/noexec/nosuid/nodev") + } + } + } + return nil +} diff --git a/sandbox/record_policy.go b/sandbox/record_policy.go new file mode 100644 index 00000000..fc8f5280 --- /dev/null +++ b/sandbox/record_policy.go @@ -0,0 +1,70 @@ +package sandbox + +import ( + "fmt" + "math" + "slices" + "strings" + "time" +) + +const ( + RecordMaxMemoryBytes = 1 << 30 + RecordMaxCPUs = 2 + RecordMaxPIDs = 256 + RecordMaxTimeout = 10 * time.Minute +) + +// ValidateRecordConfig is the parent-side policy for brokered local execution. +// Errors deliberately never include config values, environment, or credentials. +func ValidateRecordConfig(cfg SandboxConfig) error { + if cfg.GetProfile() != "strict" || cfg.User != "65532:65532" || len(cfg.Mounts) != 0 || + !cfg.ReadOnlyRootfs || !cfg.NoNewPrivileges || len(cfg.CapAdd) != 0 || + !slices.Equal(cfg.CapDrop, []string{"ALL"}) || len(cfg.SecurityOpts) != 0 { + return fmt.Errorf("sandbox: record mode requires strict nonroot mountless execution") + } + if cfg.MemoryLimit <= 0 || cfg.MemoryLimit > RecordMaxMemoryBytes || + math.IsNaN(cfg.CPULimit) || math.IsInf(cfg.CPULimit, 0) || cfg.CPULimit < 0.01 || cfg.CPULimit > RecordMaxCPUs || + cfg.PidsLimit <= 0 || cfg.PidsLimit > RecordMaxPIDs || cfg.Timeout <= 0 || cfg.Timeout > RecordMaxTimeout { + return fmt.Errorf("sandbox: record mode requires positive bounded resources") + } + if cfg.NetworkMode != "none" && cfg.NetworkMode != "bridge" { + return fmt.Errorf("sandbox: record mode requires none or bridge networking") + } + if cfg.Image == "" || strings.HasPrefix(cfg.Image, "-") || strings.ContainsAny(cfg.Image, " \t\r\n\x00") { + return fmt.Errorf("sandbox: invalid record image") + } + if err := ValidateFilesystem(cfg); err != nil { + return err + } + options := make(map[string]string) + for _, option := range strings.Split(cfg.Tmpfs["/tmp"], ",") { + key, value, _ := strings.Cut(option, "=") + options[key] = value + } + if options["mode"] != "1777" || options["uid"] != "65532" || options["gid"] != "65532" { + return fmt.Errorf("sandbox: record mode requires nonroot mode-1777 /tmp tmpfs") + } + for key, value := range cfg.Env { + if key == "" || strings.ContainsAny(key, "=\x00\r\n") || strings.ContainsRune(value, '\x00') || reservedRecordEnvironment(key) { + return fmt.Errorf("sandbox: invalid or reserved record environment") + } + } + return nil +} + +func reservedRecordEnvironment(key string) bool { + key = strings.ToUpper(key) + for _, prefix := range []string{"DOCKER_", "WFCTL_", "ACTIONS_", "REGISTRY_PROPOSER_"} { + if strings.HasPrefix(key, prefix) { + return true + } + } + switch key { + case "GITHUB_TOKEN", "GH_TOKEN", "GITHUB_PAT", "REGISTRY_TOKEN", "SSH_AUTH_SOCK", + "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", + "GOOGLE_APPLICATION_CREDENTIALS", "AZURE_CLIENT_SECRET": + return true + } + return false +} diff --git a/sandbox/record_policy_test.go b/sandbox/record_policy_test.go new file mode 100644 index 00000000..39cc1939 --- /dev/null +++ b/sandbox/record_policy_test.go @@ -0,0 +1,63 @@ +package sandbox + +import ( + "math" + "strings" + "testing" + "time" +) + +func TestValidateRecordConfig(t *testing.T) { + for _, test := range []struct { + name string + edit func(*SandboxConfig) + }{ + {"host mount", func(c *SandboxConfig) { c.Mounts = []Mount{{Source: "/secret", Target: "/work"}} }}, + {"non-strict", func(c *SandboxConfig) { c.Profile = "standard" }}, + {"root", func(c *SandboxConfig) { c.User = "0:0" }}, + {"writable root", func(c *SandboxConfig) { c.ReadOnlyRootfs = false }}, + {"privileges", func(c *SandboxConfig) { c.NoNewPrivileges = false }}, + {"add cap", func(c *SandboxConfig) { c.CapAdd = []string{"SYS_ADMIN"} }}, + {"retain caps", func(c *SandboxConfig) { c.CapDrop = nil }}, + {"security override", func(c *SandboxConfig) { c.SecurityOpts = []string{"seccomp=unconfined"} }}, + {"zero memory", func(c *SandboxConfig) { c.MemoryLimit = 0 }}, + {"unbounded memory", func(c *SandboxConfig) { c.MemoryLimit = 1<<30 + 1 }}, + {"zero cpu", func(c *SandboxConfig) { c.CPULimit = 0 }}, + {"unbounded cpu", func(c *SandboxConfig) { c.CPULimit = 2.01 }}, + {"NaN cpu", func(c *SandboxConfig) { c.CPULimit = math.NaN() }}, + {"Inf cpu", func(c *SandboxConfig) { c.CPULimit = math.Inf(1) }}, + {"zero pids", func(c *SandboxConfig) { c.PidsLimit = 0 }}, + {"unbounded pids", func(c *SandboxConfig) { c.PidsLimit = 257 }}, + {"zero timeout", func(c *SandboxConfig) { c.Timeout = 0 }}, + {"unbounded timeout", func(c *SandboxConfig) { c.Timeout = 11 * time.Minute }}, + {"host network", func(c *SandboxConfig) { c.NetworkMode = "host" }}, + {"bad workdir", func(c *SandboxConfig) { c.WorkDir = "relative" }}, + {"bad tmpfs", func(c *SandboxConfig) { c.Tmpfs["/tmp"] = "size=64m,exec" }}, + {"missing tmp", func(c *SandboxConfig) { delete(c.Tmpfs, "/tmp") }}, + {"wrong tmp mode", func(c *SandboxConfig) { c.Tmpfs["/tmp"] = "size=64m,mode=0700,uid=65532,gid=65532,noexec,nosuid,nodev" }}, + {"wrong tmp owner", func(c *SandboxConfig) { c.Tmpfs["/tmp"] = "size=64m,mode=1777,uid=0,gid=0,noexec,nosuid,nodev" }}, + {"empty image", func(c *SandboxConfig) { c.Image = "" }}, + {"image option", func(c *SandboxConfig) { c.Image = "--privileged" }}, + {"reserved env", func(c *SandboxConfig) { c.Env = map[string]string{"GITHUB_TOKEN": "TEST_SECRET_TOKEN"} }}, + {"Docker env", func(c *SandboxConfig) { c.Env = map[string]string{"docker_host": "TEST_SECRET_TOKEN"} }}, + {"invalid env", func(c *SandboxConfig) { c.Env = map[string]string{"A=B": "TEST_SECRET_TOKEN"} }}, + {"NUL env", func(c *SandboxConfig) { c.Env = map[string]string{"A": "TEST_SECRET_TOKEN\x00"} }}, + } { + t.Run(test.name, func(t *testing.T) { + cfg := DefaultSecureSandboxConfig("alpine:latest") + test.edit(&cfg) + if err := ValidateRecordConfig(cfg); err == nil || strings.Contains(err.Error(), "TEST_SECRET_TOKEN") { + t.Fatalf("unsafe record config requires redacted denial: %v", err) + } + }) + } + for _, network := range []string{"none", "bridge"} { + cfg := DefaultSecureSandboxConfig("alpine:latest") + cfg.NetworkMode, cfg.WorkDir = network, "/work" + cfg.Tmpfs["/work"] = "size=64m,mode=0700,uid=65532,gid=65532,noexec,nosuid,nodev" + cfg.Env = map[string]string{"PUBLISH_TOKEN": "explicitly-allowlisted"} + if err := ValidateRecordConfig(cfg); err != nil { + t.Fatalf("safe nonroot work tmpfs/explicit env rejected: %v", err) + } + } +} diff --git a/sandbox/runner.go b/sandbox/runner.go index a65000f7..7cfbb69b 100644 --- a/sandbox/runner.go +++ b/sandbox/runner.go @@ -4,6 +4,22 @@ import ( "context" ) +type localRunnerFactoryKey struct{} + +// WithLocalRunnerFactory binds host-owned local execution, including record-mode +// broker custody, without changing named remote/provider runner behavior. +func WithLocalRunnerFactory(ctx context.Context, factory func(SandboxConfig) (SandboxRunner, error)) context.Context { + return context.WithValue(ctx, localRunnerFactoryKey{}, factory) +} + +// NewLocalRunner uses the context's host runner, falling back to local Docker. +func NewLocalRunner(ctx context.Context, cfg SandboxConfig) (SandboxRunner, error) { + if factory, ok := ctx.Value(localRunnerFactoryKey{}).(func(SandboxConfig) (SandboxRunner, error)); ok && factory != nil { + return factory(cfg) + } + return NewLocalDockerRunner(cfg) +} + // SandboxRunner is the interface consumed by step.sandbox_exec. // Only Exec and Close are required — all other DockerSandbox methods are // lifecycle helpers not used by the step's Execute path.