mirror of
https://github.com/aykhans/sarin.git
synced 2026-02-27 22:39:13 +00:00
Replace ad-hoc fmt.Errorf/errors.New calls with typed error structs across config, sarin, and script packages to enable type-based error handling. Add script-specific error handlers in CLI entry point. Fix variable shadowing bug in Worker for scriptTransformer. Bump Go to 1.25.7 and golangci-lint to v2.8.0.
215 lines
5.8 KiB
Go
215 lines
5.8 KiB
Go
package script
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.aykhans.me/sarin/internal/types"
|
|
)
|
|
|
|
// RequestData represents the request data passed to scripts for transformation.
|
|
// Scripts can modify any field and the changes will be applied to the actual request.
|
|
// Headers, Params, and Cookies use []string values to support multiple values per key.
|
|
type RequestData struct {
|
|
Method string `json:"method"`
|
|
URL string `json:"url"`
|
|
Path string `json:"path"`
|
|
Headers map[string][]string `json:"headers"`
|
|
Params map[string][]string `json:"params"`
|
|
Cookies map[string][]string `json:"cookies"`
|
|
Body string `json:"body"`
|
|
}
|
|
|
|
// Engine defines the interface for script engines (Lua, JavaScript).
|
|
// Each engine must be able to transform request data using a user-provided script.
|
|
type Engine interface {
|
|
// Transform executes the script's transform function with the given request data.
|
|
// The script should modify the RequestData and return it.
|
|
Transform(req *RequestData) error
|
|
|
|
// Close releases any resources held by the engine.
|
|
Close()
|
|
}
|
|
|
|
// EngineType represents the type of script engine.
|
|
type EngineType string
|
|
|
|
const (
|
|
EngineTypeLua EngineType = "lua"
|
|
EngineTypeJavaScript EngineType = "js"
|
|
)
|
|
|
|
// Source represents a loaded script source.
|
|
type Source struct {
|
|
Content string
|
|
EngineType EngineType
|
|
}
|
|
|
|
// LoadSource loads a script from the given source string.
|
|
// The source can be:
|
|
// - Inline script: any string not starting with "@"
|
|
// - Escaped "@": strings starting with "@@" (literal "@" at start, returns string without first @)
|
|
// - File reference: "@/path/to/file" or "@./relative/path"
|
|
// - URL reference: "@http://..." or "@https://..."
|
|
//
|
|
// It can return the following errors:
|
|
// - types.ErrScriptEmpty
|
|
// - types.ScriptLoadError
|
|
func LoadSource(ctx context.Context, source string, engineType EngineType) (*Source, error) {
|
|
if source == "" {
|
|
return nil, types.ErrScriptEmpty
|
|
}
|
|
|
|
var content string
|
|
var err error
|
|
|
|
switch {
|
|
case strings.HasPrefix(source, "@@"):
|
|
// Escaped @ - it's an inline script starting with literal @
|
|
content = source[1:] // Remove first @, keep the rest
|
|
case strings.HasPrefix(source, "@"):
|
|
// File or URL reference
|
|
ref := source[1:]
|
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
|
content, err = fetchURL(ctx, ref)
|
|
} else {
|
|
content, err = readFile(ref)
|
|
}
|
|
if err != nil {
|
|
return nil, types.NewScriptLoadError(ref, err)
|
|
}
|
|
default:
|
|
// Inline script
|
|
content = source
|
|
}
|
|
|
|
return &Source{
|
|
Content: content,
|
|
EngineType: engineType,
|
|
}, nil
|
|
}
|
|
|
|
// LoadSources loads multiple script sources.
|
|
// It can return the following errors:
|
|
// - types.ErrScriptEmpty
|
|
// - types.ScriptLoadError
|
|
func LoadSources(ctx context.Context, sources []string, engineType EngineType) ([]*Source, error) {
|
|
loaded := make([]*Source, 0, len(sources))
|
|
for _, src := range sources {
|
|
source, err := LoadSource(ctx, src, engineType)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
loaded = append(loaded, source)
|
|
}
|
|
return loaded, nil
|
|
}
|
|
|
|
// ValidateScript validates a script source by loading it and checking syntax.
|
|
// It loads the script (from file/URL/inline), parses it, and verifies
|
|
// that a 'transform' function is defined.
|
|
// It can return the following errors:
|
|
// - types.ErrScriptEmpty
|
|
// - types.ErrScriptTransformMissing
|
|
// - types.ScriptLoadError
|
|
// - types.ScriptExecutionError
|
|
// - types.ScriptUnknownEngineError
|
|
func ValidateScript(ctx context.Context, source string, engineType EngineType) error {
|
|
// Load the script source
|
|
src, err := LoadSource(ctx, source, engineType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Try to create an engine - this validates syntax and transform function
|
|
var engine Engine
|
|
switch engineType {
|
|
case EngineTypeLua:
|
|
engine, err = NewLuaEngine(src.Content)
|
|
case EngineTypeJavaScript:
|
|
engine, err = NewJsEngine(src.Content)
|
|
default:
|
|
return types.NewScriptUnknownEngineError(string(engineType))
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Clean up the engine - we only needed it for validation
|
|
engine.Close()
|
|
return nil
|
|
}
|
|
|
|
// ValidateScripts validates multiple script sources.
|
|
// It can return the following errors:
|
|
// - types.ErrScriptEmpty
|
|
// - types.ErrScriptTransformMissing
|
|
// - types.ScriptLoadError
|
|
// - types.ScriptExecutionError
|
|
// - types.ScriptUnknownEngineError
|
|
func ValidateScripts(ctx context.Context, sources []string, engineType EngineType) error {
|
|
for _, src := range sources {
|
|
if err := ValidateScript(ctx, src, engineType); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fetchURL downloads content from an HTTP/HTTPS URL.
|
|
// It can return the following errors:
|
|
// - types.HTTPFetchError
|
|
// - types.HTTPStatusError
|
|
func fetchURL(ctx context.Context, url string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
|
defer cancel()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", types.NewHTTPFetchError(url, err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", types.NewHTTPFetchError(url, err)
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", types.NewHTTPStatusError(url, resp.StatusCode, resp.Status)
|
|
}
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return "", types.NewHTTPFetchError(url, err)
|
|
}
|
|
|
|
return string(data), nil
|
|
}
|
|
|
|
// readFile reads content from a local file.
|
|
// It can return the following errors:
|
|
// - types.FileReadError
|
|
func readFile(path string) (string, error) {
|
|
if !filepath.IsAbs(path) {
|
|
pwd, err := os.Getwd()
|
|
if err != nil {
|
|
return "", types.NewFileReadError(path, err)
|
|
}
|
|
path = filepath.Join(pwd, path)
|
|
}
|
|
|
|
data, err := os.ReadFile(path) //nolint:gosec
|
|
if err != nil {
|
|
return "", types.NewFileReadError(path, err)
|
|
}
|
|
|
|
return string(data), nil
|
|
}
|