Address medium severity security issues: - Validate repo names in config only allow alphanumeric, dash, underscore (prevents NATS subject injection via dots or wildcards) - Validate repo URLs must start with git+https://, git+ssh://, or git+file:// - Validate ReplyTo field must start with "build.responses." to prevent publishing responses to arbitrary NATS subjects Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
135 lines
4.0 KiB
Go
135 lines
4.0 KiB
Go
package messages
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// BuildStatus represents the status of a build response.
|
|
type BuildStatus string
|
|
|
|
const (
|
|
BuildStatusStarted BuildStatus = "started"
|
|
BuildStatusProgress BuildStatus = "progress"
|
|
BuildStatusCompleted BuildStatus = "completed"
|
|
BuildStatusFailed BuildStatus = "failed"
|
|
BuildStatusRejected BuildStatus = "rejected"
|
|
)
|
|
|
|
// IsFinal returns true if this status indicates a terminal state.
|
|
func (s BuildStatus) IsFinal() bool {
|
|
switch s {
|
|
case BuildStatusCompleted, BuildStatusFailed, BuildStatusRejected:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// BuildRequest is the message sent to request a build.
|
|
type BuildRequest struct {
|
|
Repo string `json:"repo"` // Must match config
|
|
Target string `json:"target"` // Hostname or "all"
|
|
Branch string `json:"branch,omitempty"` // Optional, uses repo default
|
|
ReplyTo string `json:"reply_to"`
|
|
}
|
|
|
|
// Validate checks that the request is valid.
|
|
func (r *BuildRequest) Validate() error {
|
|
if r.Repo == "" {
|
|
return fmt.Errorf("repo is required")
|
|
}
|
|
if !revisionRegex.MatchString(r.Repo) {
|
|
return fmt.Errorf("invalid repo name format: %q", r.Repo)
|
|
}
|
|
if r.Target == "" {
|
|
return fmt.Errorf("target is required")
|
|
}
|
|
// Target must be "all" or a valid hostname (same format as revision/branch)
|
|
if r.Target != "all" && !revisionRegex.MatchString(r.Target) {
|
|
return fmt.Errorf("invalid target format: %q", r.Target)
|
|
}
|
|
if r.Branch != "" && !revisionRegex.MatchString(r.Branch) {
|
|
return fmt.Errorf("invalid branch format: %q", r.Branch)
|
|
}
|
|
if r.ReplyTo == "" {
|
|
return fmt.Errorf("reply_to is required")
|
|
}
|
|
// Validate reply_to format to prevent publishing to arbitrary subjects
|
|
if !strings.HasPrefix(r.ReplyTo, "build.responses.") {
|
|
return fmt.Errorf("invalid reply_to format: must start with 'build.responses.'")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Marshal serializes the request to JSON.
|
|
func (r *BuildRequest) Marshal() ([]byte, error) {
|
|
return json.Marshal(r)
|
|
}
|
|
|
|
// UnmarshalBuildRequest deserializes a request from JSON.
|
|
func UnmarshalBuildRequest(data []byte) (*BuildRequest, error) {
|
|
var r BuildRequest
|
|
if err := json.Unmarshal(data, &r); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal build request: %w", err)
|
|
}
|
|
return &r, nil
|
|
}
|
|
|
|
// BuildHostResult contains the result of building a single host.
|
|
type BuildHostResult struct {
|
|
Host string `json:"host"`
|
|
Success bool `json:"success"`
|
|
Error string `json:"error,omitempty"`
|
|
DurationSeconds float64 `json:"duration_seconds"`
|
|
}
|
|
|
|
// BuildResponse is the message sent in response to a build request.
|
|
type BuildResponse struct {
|
|
Status BuildStatus `json:"status"`
|
|
Message string `json:"message,omitempty"`
|
|
|
|
// Progress updates
|
|
Host string `json:"host,omitempty"`
|
|
HostSuccess *bool `json:"host_success,omitempty"`
|
|
HostsCompleted int `json:"hosts_completed,omitempty"`
|
|
HostsTotal int `json:"hosts_total,omitempty"`
|
|
|
|
// Final response
|
|
Results []BuildHostResult `json:"results,omitempty"`
|
|
TotalDurationSeconds float64 `json:"total_duration_seconds,omitempty"`
|
|
Succeeded int `json:"succeeded,omitempty"`
|
|
Failed int `json:"failed,omitempty"`
|
|
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// NewBuildResponse creates a new response with the given status and message.
|
|
func NewBuildResponse(status BuildStatus, message string) *BuildResponse {
|
|
return &BuildResponse{
|
|
Status: status,
|
|
Message: message,
|
|
}
|
|
}
|
|
|
|
// WithError adds an error message to the response.
|
|
func (r *BuildResponse) WithError(err string) *BuildResponse {
|
|
r.Error = err
|
|
return r
|
|
}
|
|
|
|
// Marshal serializes the response to JSON.
|
|
func (r *BuildResponse) Marshal() ([]byte, error) {
|
|
return json.Marshal(r)
|
|
}
|
|
|
|
// UnmarshalBuildResponse deserializes a response from JSON.
|
|
func UnmarshalBuildResponse(data []byte) (*BuildResponse, error) {
|
|
var r BuildResponse
|
|
if err := json.Unmarshal(data, &r); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal build response: %w", err)
|
|
}
|
|
return &r, nil
|
|
}
|