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.