@@ -0,0 +1,153 @@ |
| 1 | +// SPDX-License-Identifier: AGPL-3.0-or-later |
| 2 | + |
| 3 | +// Package dispatch holds the input-validation primitives shared by |
| 4 | +// the HTML and REST workflow_dispatch handlers. Both surfaces accept |
| 5 | +// a `(workflow_file, ref, inputs)` triple and need to: |
| 6 | +// |
| 7 | +// - Validate the workflow_file path is well-formed and inside |
| 8 | +// `.shithub/workflows/`. |
| 9 | +// - Normalize the caller's inputs against the workflow's declared |
| 10 | +// `on.workflow_dispatch.inputs` (booleans become "true"/"false" |
| 11 | +// strings, required inputs are enforced, choice options validated, |
| 12 | +// unknown input names rejected). |
| 13 | +// - Parse `inputs.<name>` form fields when the request is |
| 14 | +// form-encoded. |
| 15 | +// |
| 16 | +// Keeping these helpers in their own package means the HTML + REST |
| 17 | +// handlers can't drift on workflow_dispatch semantics — both feed |
| 18 | +// through the same code path. |
| 19 | +package dispatch |
| 20 | + |
| 21 | +import ( |
| 22 | + "fmt" |
| 23 | + "net/url" |
| 24 | + "strings" |
| 25 | + |
| 26 | + "github.com/tenseleyFlow/shithub/internal/actions/workflow" |
| 27 | +) |
| 28 | + |
| 29 | +// WorkflowFilesDir is the canonical location of shithub workflow files |
| 30 | +// inside a repo. Exported so both surfaces share the literal. |
| 31 | +const WorkflowFilesDir = ".shithub/workflows/" |
| 32 | + |
| 33 | +// NormalizeFilePath accepts either a basename ("ci.yml") or a full |
| 34 | +// repo-relative path (".shithub/workflows/ci.yml") and returns the |
| 35 | +// canonical full path. Returns ErrInvalidWorkflowName if the result |
| 36 | +// would escape `.shithub/workflows/` or doesn't end in `.yml`/`.yaml`. |
| 37 | +func NormalizeFilePath(file string) (string, error) { |
| 38 | + file = strings.TrimSpace(file) |
| 39 | + if file == "" { |
| 40 | + return "", ErrInvalidWorkflowName |
| 41 | + } |
| 42 | + if !strings.HasPrefix(file, WorkflowFilesDir) { |
| 43 | + file = WorkflowFilesDir + file |
| 44 | + } |
| 45 | + if strings.Contains(file, "..") || !ValidWorkflowName(file) { |
| 46 | + return "", ErrInvalidWorkflowName |
| 47 | + } |
| 48 | + return file, nil |
| 49 | +} |
| 50 | + |
| 51 | +// ValidWorkflowName guards against URL parameter shenanigans by |
| 52 | +// requiring the resolved file path to look like |
| 53 | +// `.shithub/workflows/<basename>.{yml,yaml}` with no path traversal. |
| 54 | +func ValidWorkflowName(file string) bool { |
| 55 | + if !strings.HasPrefix(file, WorkflowFilesDir) { |
| 56 | + return false |
| 57 | + } |
| 58 | + rest := strings.TrimPrefix(file, WorkflowFilesDir) |
| 59 | + if rest == "" || strings.ContainsAny(rest, "/\x00") { |
| 60 | + return false |
| 61 | + } |
| 62 | + return strings.HasSuffix(rest, ".yml") || strings.HasSuffix(rest, ".yaml") |
| 63 | +} |
| 64 | + |
| 65 | +// ErrInvalidWorkflowName signals a malformed workflow_file path. |
| 66 | +// Callers map this to a 400-shaped response. |
| 67 | +var ErrInvalidWorkflowName = fmt.Errorf("dispatch: invalid workflow file path") |
| 68 | + |
| 69 | +// NormalizeInputs validates and normalises caller-supplied inputs |
| 70 | +// against a workflow's declared `on.workflow_dispatch.inputs`. |
| 71 | +// |
| 72 | +// - Unknown input names → error. |
| 73 | +// - Missing required (non-boolean) inputs → error. |
| 74 | +// - Booleans must be "true"/"false"; missing booleans default to |
| 75 | +// "false" (matches GHA semantics). |
| 76 | +// - Choices must match one of the declared options. |
| 77 | +// - Empty optional inputs with a default get the default applied. |
| 78 | +// |
| 79 | +// The returned map is the value the trigger pipeline records on the |
| 80 | +// workflow run; nil is valid (means "no inputs apply"). |
| 81 | +func NormalizeInputs(raw map[string]string, specs []workflow.DispatchInput) (map[string]string, error) { |
| 82 | + if raw == nil { |
| 83 | + raw = map[string]string{} |
| 84 | + } |
| 85 | + known := make(map[string]workflow.DispatchInput, len(specs)) |
| 86 | + for _, spec := range specs { |
| 87 | + known[spec.Name] = spec |
| 88 | + } |
| 89 | + for name := range raw { |
| 90 | + if _, ok := known[name]; !ok { |
| 91 | + return nil, fmt.Errorf("unknown workflow_dispatch input %q", name) |
| 92 | + } |
| 93 | + } |
| 94 | + |
| 95 | + out := make(map[string]string, len(specs)) |
| 96 | + for _, spec := range specs { |
| 97 | + value, provided := raw[spec.Name] |
| 98 | + if !provided || value == "" { |
| 99 | + value = spec.Default |
| 100 | + } |
| 101 | + if spec.Type == "boolean" && !provided && value == "" { |
| 102 | + value = "false" |
| 103 | + } |
| 104 | + if spec.Required && spec.Type != "boolean" && strings.TrimSpace(value) == "" { |
| 105 | + return nil, fmt.Errorf("workflow_dispatch input %q is required", spec.Name) |
| 106 | + } |
| 107 | + switch spec.Type { |
| 108 | + case "boolean": |
| 109 | + if value != "true" && value != "false" { |
| 110 | + return nil, fmt.Errorf("workflow_dispatch input %q must be true or false", spec.Name) |
| 111 | + } |
| 112 | + case "choice": |
| 113 | + if value != "" && !choiceAllowed(value, spec.Options) { |
| 114 | + return nil, fmt.Errorf("workflow_dispatch input %q must be one of the declared options", spec.Name) |
| 115 | + } |
| 116 | + } |
| 117 | + if value != "" || spec.Type == "boolean" { |
| 118 | + out[spec.Name] = value |
| 119 | + } |
| 120 | + } |
| 121 | + if len(out) == 0 { |
| 122 | + return nil, nil |
| 123 | + } |
| 124 | + return out, nil |
| 125 | +} |
| 126 | + |
| 127 | +// InputsFromForm pulls `inputs.<name>` keys out of a form-encoded |
| 128 | +// body. The HTML dispatch UI submits inputs this way; the REST |
| 129 | +// surface prefers JSON but also accepts this shape so curl ergonomics |
| 130 | +// work without a JSON body. |
| 131 | +func InputsFromForm(values url.Values) map[string]string { |
| 132 | + inputs := make(map[string]string) |
| 133 | + for key, vals := range values { |
| 134 | + name, ok := strings.CutPrefix(key, "inputs.") |
| 135 | + if !ok || name == "" || len(vals) == 0 { |
| 136 | + continue |
| 137 | + } |
| 138 | + inputs[name] = vals[len(vals)-1] |
| 139 | + } |
| 140 | + if len(inputs) == 0 { |
| 141 | + return nil |
| 142 | + } |
| 143 | + return inputs |
| 144 | +} |
| 145 | + |
| 146 | +func choiceAllowed(value string, options []string) bool { |
| 147 | + for _, option := range options { |
| 148 | + if value == option { |
| 149 | + return true |
| 150 | + } |
| 151 | + } |
| 152 | + return false |
| 153 | +} |