Files
sarin/internal/config/file.go
T

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)
}