mirror of
https://github.com/aykhans/sarin.git
synced 2026-10-14 21:58:51 +00:00
519 lines
16 KiB
Go
519 lines
16 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.aykhans.me/sarin/internal/types"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
var _ IParser = ConfigFileParser{}
|
|
|
|
type ConfigFileParser struct {
|
|
configFile types.ConfigFile
|
|
}
|
|
|
|
func NewConfigFileParser(configFile types.ConfigFile) *ConfigFileParser {
|
|
return &ConfigFileParser{configFile}
|
|
}
|
|
|
|
// Parse parses config file arguments into a Config object.
|
|
// It can return the following errors:
|
|
// - types.ConfigFileReadError
|
|
// - types.UnmarshalError
|
|
// - types.FieldParseErrors
|
|
func (parser ConfigFileParser) Parse() (*Config, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
|
|
defer cancel()
|
|
|
|
configFileData, err := fetchFile(ctx, parser.configFile.Path())
|
|
if err != nil {
|
|
return nil, types.NewConfigFileReadError(err)
|
|
}
|
|
|
|
switch parser.configFile.Type() {
|
|
case types.ConfigFileTypeYAML, types.ConfigFileTypeUnknown:
|
|
return parser.ParseYAML(configFileData)
|
|
default:
|
|
panic("unhandled config file type")
|
|
}
|
|
}
|
|
|
|
// fetchFile retrieves file contents from a local path or HTTP/HTTPS URL.
|
|
// It can return the following errors:
|
|
// - types.FileReadError
|
|
// - types.HTTPFetchError
|
|
// - types.HTTPStatusError
|
|
func fetchFile(ctx context.Context, src string) ([]byte, error) {
|
|
if strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://") {
|
|
return fetchHTTP(ctx, src)
|
|
}
|
|
return fetchLocal(src)
|
|
}
|
|
|
|
// fetchHTTP downloads file contents from an HTTP/HTTPS URL.
|
|
// It can return the following errors:
|
|
// - types.HTTPFetchError
|
|
// - types.HTTPStatusError
|
|
func fetchHTTP(ctx context.Context, url string) ([]byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, types.NewHTTPFetchError(url, err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, types.NewHTTPFetchError(url, err)
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
|
}
|
|
|
|
// A declared size above the limit is refused before anything is read.
|
|
if resp.ContentLength > types.RemoteFetchLimit {
|
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
|
}
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, types.RemoteFetchLimit+1))
|
|
if err != nil {
|
|
return nil, types.NewHTTPFetchError(url, err)
|
|
}
|
|
if len(data) > types.RemoteFetchLimit {
|
|
return nil, types.NewHTTPFetchError(url, types.ErrRemoteFileTooLarge)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
// fetchLocal reads file contents from the local filesystem.
|
|
// It resolves relative paths from the current working directory.
|
|
// It can return the following errors:
|
|
// - types.FileReadError
|
|
func fetchLocal(src string) ([]byte, error) {
|
|
path := src
|
|
if !filepath.IsAbs(src) {
|
|
pwd, err := os.Getwd()
|
|
if err != nil {
|
|
return nil, types.NewFileReadError(src, err)
|
|
}
|
|
path = filepath.Join(pwd, src)
|
|
}
|
|
|
|
data, err := os.ReadFile(path) //nolint:gosec
|
|
if err != nil {
|
|
return nil, types.NewFileReadError(path, err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
type stringOrSliceField []string
|
|
|
|
func (ss *stringOrSliceField) UnmarshalYAML(node *yaml.Node) error {
|
|
switch node.Kind {
|
|
case yaml.ScalarNode:
|
|
// Handle single string value
|
|
*ss = []string{node.Value}
|
|
return nil
|
|
case yaml.SequenceNode:
|
|
// Handle array of strings
|
|
var slice []string
|
|
if err := node.Decode(&slice); err != nil {
|
|
return err //nolint:wrapcheck
|
|
}
|
|
*ss = slice
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("expected a string or a sequence of strings, but got %s", yamlNodeName(node))
|
|
}
|
|
}
|
|
|
|
// keyValuesField handles flexible YAML formats for key-value pairs.
|
|
// Supported formats:
|
|
// - Sequence of maps: [{key1: value1}, {key2: [value2, value3]}]
|
|
// - Single map: {key1: value1, key2: [value2, value3]}
|
|
//
|
|
// Values can be either a single string or an array of strings.
|
|
type keyValuesField []types.KeyValue[string, []string]
|
|
|
|
func (kv *keyValuesField) UnmarshalYAML(node *yaml.Node) error {
|
|
switch node.Kind {
|
|
case yaml.MappingNode:
|
|
// Handle single map: {key1: value1, key2: [value2]}
|
|
return kv.unmarshalMapping(node)
|
|
case yaml.SequenceNode:
|
|
// Handle sequence of maps: [{key1: value1}, {key2: value2}]
|
|
for _, item := range node.Content {
|
|
if item.Kind != yaml.MappingNode {
|
|
return fmt.Errorf("expected a mapping in sequence, but got %s", yamlNodeName(item))
|
|
}
|
|
if err := kv.unmarshalMapping(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("expected a mapping or sequence of mappings, but got %s", yamlNodeName(node))
|
|
}
|
|
}
|
|
|
|
func (kv *keyValuesField) unmarshalMapping(node *yaml.Node) error {
|
|
// MappingNode content is [key1, value1, key2, value2, ...]
|
|
for i := 0; i < len(node.Content); i += 2 {
|
|
keyNode := node.Content[i]
|
|
valueNode := node.Content[i+1]
|
|
|
|
if keyNode.Kind != yaml.ScalarNode {
|
|
return fmt.Errorf("expected a string key, but got %s", yamlNodeName(keyNode))
|
|
}
|
|
|
|
key := keyNode.Value
|
|
var values []string
|
|
|
|
switch valueNode.Kind {
|
|
case yaml.ScalarNode:
|
|
values = []string{valueNode.Value}
|
|
case yaml.SequenceNode:
|
|
for _, v := range valueNode.Content {
|
|
if v.Kind != yaml.ScalarNode {
|
|
return fmt.Errorf("expected string values in array for key %q, but got %s", key, yamlNodeName(v))
|
|
}
|
|
values = append(values, v.Value)
|
|
}
|
|
default:
|
|
return fmt.Errorf("expected a string or array of strings for key %q, but got %s", key, yamlNodeName(valueNode))
|
|
}
|
|
|
|
*kv = append(*kv, types.KeyValue[string, []string]{Key: key, Value: values})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type configYAML struct {
|
|
ShowConfig *bool `yaml:"showConfig"`
|
|
ConfigFiles stringOrSliceField `yaml:"configFile"`
|
|
Concurrency *uint `yaml:"concurrency"`
|
|
RequestCount *uint64 `yaml:"requests"`
|
|
Duration *time.Duration `yaml:"duration"`
|
|
LogLevel *string `yaml:"logLevel"`
|
|
LogFile *string `yaml:"logFile"`
|
|
Progress *string `yaml:"progress"`
|
|
Output *string `yaml:"output"`
|
|
DryRun *bool `yaml:"dryRun"`
|
|
URL *string `yaml:"url"`
|
|
Method stringOrSliceField `yaml:"method"`
|
|
Bodies stringOrSliceField `yaml:"body"`
|
|
Params keyValuesField `yaml:"params"`
|
|
Headers keyValuesField `yaml:"headers"`
|
|
Cookies keyValuesField `yaml:"cookies"`
|
|
Proxies stringOrSliceField `yaml:"proxy"`
|
|
Values stringOrSliceField `yaml:"values"`
|
|
Timeout *time.Duration `yaml:"timeout"`
|
|
MaxResponseBody *byteSize `yaml:"maxResponseBody"`
|
|
Insecure *bool `yaml:"insecure"`
|
|
Lua stringOrSliceField `yaml:"lua"`
|
|
Js stringOrSliceField `yaml:"js"`
|
|
}
|
|
|
|
// ParseYAML parses YAML config file arguments into a Config object.
|
|
// It can return the following errors:
|
|
// - types.UnmarshalError
|
|
// - types.FieldParseErrors
|
|
func (parser ConfigFileParser) ParseYAML(data []byte) (*Config, error) {
|
|
var (
|
|
config = &Config{}
|
|
parsedData = &configYAML{}
|
|
)
|
|
|
|
decoder := yaml.NewDecoder(bytes.NewReader(data))
|
|
decoder.KnownFields(true)
|
|
// The decoder returns a bare io.EOF for an empty file, errors.Is would also match a wrapped one.
|
|
if err := decoder.Decode(parsedData); err != nil && err != io.EOF { //nolint:errorlint
|
|
return nil, types.NewUnmarshalError(yamlError(err))
|
|
}
|
|
|
|
// Decode reads one document, so anything after the first "---" would be dropped silently.
|
|
for {
|
|
var next yaml.Node
|
|
err := decoder.Decode(&next)
|
|
if err == io.EOF { //nolint:errorlint
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, types.NewUnmarshalError(yamlError(err))
|
|
}
|
|
if !isEmptyDocument(&next) {
|
|
return nil, types.NewUnmarshalError(types.ErrYAMLMultipleDocuments)
|
|
}
|
|
}
|
|
|
|
var fieldParseErrors []types.FieldParseError
|
|
|
|
config.ShowConfig = parsedData.ShowConfig
|
|
|
|
if len(parsedData.ConfigFiles) > 0 {
|
|
for _, configFile := range parsedData.ConfigFiles {
|
|
config.Files = append(config.Files, *types.ParseConfigFile(configFile))
|
|
}
|
|
}
|
|
|
|
config.Concurrency = parsedData.Concurrency
|
|
config.Requests = parsedData.RequestCount
|
|
config.Duration = parsedData.Duration
|
|
config.LogLevel = parsedData.LogLevel
|
|
config.LogFile = parsedData.LogFile
|
|
|
|
if parsedData.Progress != nil {
|
|
config.Progress = new(ConfigProgressType(*parsedData.Progress))
|
|
}
|
|
|
|
if parsedData.Output != nil {
|
|
config.Output = new(ConfigOutputType(*parsedData.Output))
|
|
}
|
|
|
|
config.DryRun = parsedData.DryRun
|
|
|
|
if parsedData.URL != nil {
|
|
urlParsed, err := url.Parse(*parsedData.URL)
|
|
if err != nil {
|
|
fieldParseErrors = append(fieldParseErrors, types.NewFieldParseError("url", *parsedData.URL, err))
|
|
} else {
|
|
config.URL = urlParsed
|
|
}
|
|
}
|
|
|
|
config.Methods = append(config.Methods, parsedData.Method...)
|
|
config.Bodies = append(config.Bodies, parsedData.Bodies...)
|
|
for _, kv := range parsedData.Params {
|
|
config.Params = append(config.Params, types.Param(kv))
|
|
}
|
|
for _, kv := range parsedData.Headers {
|
|
config.Headers = append(config.Headers, types.Header(kv))
|
|
}
|
|
for _, kv := range parsedData.Cookies {
|
|
config.Cookies = append(config.Cookies, types.Cookie(kv))
|
|
}
|
|
|
|
for i, proxy := range parsedData.Proxies {
|
|
err := config.Proxies.Parse(proxy)
|
|
if err != nil {
|
|
fieldParseErrors = append(
|
|
fieldParseErrors,
|
|
types.NewFieldParseError(fmt.Sprintf("proxy[%d]", i), proxy, err),
|
|
)
|
|
}
|
|
}
|
|
|
|
config.Values = append(config.Values, parsedData.Values...)
|
|
config.Timeout = parsedData.Timeout
|
|
if parsedData.MaxResponseBody != nil {
|
|
config.MaxResponseBody = new(uint64(*parsedData.MaxResponseBody))
|
|
}
|
|
config.Insecure = parsedData.Insecure
|
|
config.Lua = append(config.Lua, parsedData.Lua...)
|
|
config.Js = append(config.Js, parsedData.Js...)
|
|
|
|
if len(fieldParseErrors) > 0 {
|
|
return nil, types.NewFieldParseErrors(fieldParseErrors)
|
|
}
|
|
|
|
return config, nil
|
|
}
|
|
|
|
// configYAMLTypeName is the Go type name the yaml package puts in its messages.
|
|
var configYAMLTypeName = reflect.TypeFor[configYAML]().String()
|
|
|
|
// isEmptyDocument reports whether a document holds nothing, which a trailing "---" produces.
|
|
// A null tag alone is not enough, since a user can hang it on a mapping that does hold content.
|
|
func isEmptyDocument(node *yaml.Node) bool {
|
|
if len(node.Content) == 0 {
|
|
return true
|
|
}
|
|
root := node.Content[0]
|
|
return root.Kind == yaml.ScalarNode && root.Tag == "!!null"
|
|
}
|
|
|
|
// yamlError rewrites yaml decode errors so they read like config file errors.
|
|
// Errors from building the config arrive collected, everything earlier arrives on its own.
|
|
func yamlError(err error) error {
|
|
var loadErrors *yaml.LoadErrors
|
|
if errors.As(err, &loadErrors) && len(loadErrors.Errors) > 0 {
|
|
var builder strings.Builder
|
|
for i, loadError := range loadErrors.Errors {
|
|
if i > 0 {
|
|
builder.WriteString("\n")
|
|
}
|
|
builder.WriteString(yamlErrorText(loadError))
|
|
}
|
|
return errors.New(builder.String())
|
|
}
|
|
|
|
if loadError, ok := errors.AsType[*yaml.LoadError](err); ok {
|
|
return errors.New(yamlErrorText(loadError))
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
// yamlErrorText renders one error as "line 3: while scanning a plain scalar: found a tab character".
|
|
// The mark is where reading gave up and the context mark starts the construct that broke.
|
|
func yamlErrorText(loadError *yaml.LoadError) string {
|
|
var builder strings.Builder
|
|
if loadError.Mark.Line > 0 {
|
|
fmt.Fprintf(&builder, "line %d: ", loadError.Mark.Line)
|
|
}
|
|
if loadError.ContextMsg != "" {
|
|
builder.WriteString(loadError.ContextMsg + ": ")
|
|
}
|
|
builder.WriteString(yamlErrorMessage(loadError.Message))
|
|
|
|
start := loadError.ContextMark
|
|
if loadError.ContextMsg != "" && start.Line > 0 && start.Line != loadError.Mark.Line {
|
|
fmt.Fprintf(&builder, " (started at line %d)", start.Line)
|
|
}
|
|
|
|
return builder.String()
|
|
}
|
|
|
|
// yamlTypeNames maps the Go types and YAML tags the yaml package prints to words a user knows.
|
|
var yamlTypeNames = map[string]string{
|
|
"!!str": "text",
|
|
"!!int": "a number",
|
|
"!!float": "a number",
|
|
"!!bool": "true or false",
|
|
"!!seq": "a list",
|
|
"!!map": "a mapping",
|
|
"!!null": "an empty value",
|
|
"!!timestamp": "a date",
|
|
"!!binary": "binary data",
|
|
"string": "text",
|
|
"bool": "true or false",
|
|
"uint": "a whole number of 0 or more",
|
|
"uint64": "a whole number of 0 or more",
|
|
"time.Duration": "a duration such as 10s",
|
|
configYAMLTypeName: "a mapping of config keys",
|
|
}
|
|
|
|
// yamlNodeName names what a node holds, so an error can say "a list" instead of a kind number.
|
|
// A tag that contradicts the shape is kept, since the shape alone would read as a contradiction.
|
|
func yamlNodeName(node *yaml.Node) string {
|
|
if name, ok := yamlTypeNames[node.Tag]; ok && yamlTagKind(node.Tag) == node.Kind {
|
|
return name
|
|
}
|
|
if node.Tag == "" {
|
|
return yamlKindName(node.Kind)
|
|
}
|
|
return yamlKindName(node.Kind) + " tagged " + node.Tag
|
|
}
|
|
|
|
// yamlTagKind returns the node shape a tag belongs on, so a mismatch can be spotted.
|
|
func yamlTagKind(tag string) yaml.Kind {
|
|
switch tag {
|
|
case "!!seq":
|
|
return yaml.SequenceNode
|
|
case "!!map":
|
|
return yaml.MappingNode
|
|
default:
|
|
return yaml.ScalarNode
|
|
}
|
|
}
|
|
|
|
// yamlKindName names a node by its shape, for when its tag says nothing a user would know.
|
|
func yamlKindName(kind yaml.Kind) string {
|
|
switch kind {
|
|
case yaml.SequenceNode:
|
|
return "a list"
|
|
case yaml.MappingNode:
|
|
return "a mapping"
|
|
case yaml.AliasNode:
|
|
return "an alias"
|
|
default:
|
|
return "a value"
|
|
}
|
|
}
|
|
|
|
// yamlErrorMessage turns "field x not found in type config.configYAML" into "unknown key "x"".
|
|
func yamlErrorMessage(message string) string {
|
|
if field, ok := strings.CutSuffix(message, " not found in type "+configYAMLTypeName); ok {
|
|
if name, ok := strings.CutPrefix(field, "field "); ok {
|
|
return fmt.Sprintf("unknown key %q", name)
|
|
}
|
|
}
|
|
|
|
// The user controls the value, so the rightmost separator is the only one that is ours.
|
|
// After "as a" comes the tag the user wrote, after "into" comes the type the key accepts.
|
|
if names, ok := strings.CutPrefix(message, "cannot construct "); ok {
|
|
at, width, tagged := -1, 0, false
|
|
for index, separator := range []string{" into ", " as a "} {
|
|
if found := strings.LastIndex(names, separator); found > at {
|
|
at, width, tagged = found, len(separator), index == 1
|
|
}
|
|
}
|
|
if at >= 0 {
|
|
got, want := yamlGotName(names[:at]), yamlWantedName(names[at+width:])
|
|
if tagged {
|
|
return fmt.Sprintf("%s is not %s", got, want)
|
|
}
|
|
return fmt.Sprintf("expected %s, but got %s", want, got)
|
|
}
|
|
}
|
|
|
|
return strings.ReplaceAll(message, configYAMLTypeName, "the config file")
|
|
}
|
|
|
|
// yamlWantedName names what a key accepts, which the yaml package prints as a Go type.
|
|
func yamlWantedName(text string) string {
|
|
if name, ok := yamlTypeNames[text]; ok {
|
|
return name
|
|
}
|
|
return text
|
|
}
|
|
|
|
// yamlGotName names what the file held, which the yaml package prints as a tag and sometimes a value.
|
|
// A tag the user would not know stays in the text, since without it the message reads as nonsense.
|
|
func yamlGotName(text string) string {
|
|
// A collection tag carries no backticks, so a scalar wearing one arrives glued to it.
|
|
for _, tag := range []string{"!!seq", "!!map"} {
|
|
if value, ok := strings.CutPrefix(text, tag); ok && value != "" {
|
|
return yamlValueText(value) + " tagged " + tag
|
|
}
|
|
}
|
|
|
|
tag, value, _ := strings.Cut(text, " ")
|
|
value = strings.TrimSuffix(strings.TrimPrefix(value, "`"), "`")
|
|
|
|
name, known := yamlTypeNames[tag]
|
|
switch {
|
|
case known && value != "":
|
|
return yamlValueText(value)
|
|
case known:
|
|
return name
|
|
case value != "":
|
|
return yamlValueText(value) + " tagged " + tag
|
|
default:
|
|
return "a value tagged " + tag
|
|
}
|
|
}
|
|
|
|
// yamlValueText quotes a value, saying so when the yaml package shortened it to seven characters.
|
|
func yamlValueText(value string) string {
|
|
if start, ok := strings.CutSuffix(value, "..."); ok && len(start) == 7 {
|
|
return "a value starting with " + strconv.Quote(start)
|
|
}
|
|
return strconv.Quote(value)
|
|
}
|