tenseleyflow/shithub / 0b2c834

Browse files

actions/dispatch: extract workflow_dispatch input validation

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
0b2c834aa5e57060fe821d3ea0702fcd050b7129
Parents
72a8056
Tree
c2e01f3

1 changed file

StatusFile+-
A internal/actions/dispatch/dispatch.go 153 0
internal/actions/dispatch/dispatch.goadded
@@ -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
+}