Files
relspecgo/pkg/assetloader/embed.go
T
warkanum 1d93255575 fix(assets): improve error reporting for asset failures
fix(embed): update embedDirectives to return result directly

fix(executor): clean up import order and formatting

fix(syntax_test): change file permission syntax to octal

fix(mysql): update file permission syntax to octal

fix(pgsql): update file permission syntax to octal

fix(sqlex): simplify Walk function parameter handling

fix(transform): adjust identifier comparison logic

fix(template): clean up function formatting and error handling
2026-10-12 22:04:40 +02:00

241 lines
7.0 KiB
Go

package assetloader
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"unicode/utf8"
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
"git.warky.dev/wdevs/relspecgo/pkg/sqllex"
)
const ScriptSourcePathMetadataKey = "source_path"
const DefaultMaxAssetBytes int64 = 64 << 20 // 64 MiB per manifest/embedded asset
// EmbedOptions are trusted caller configuration, not script/model metadata.
// An empty AssetRoot defaults to the SQL file's directory; external reads are rejected.
type EmbedOptions struct {
AssetRoot string
MaxFileBytes int64
}
var (
embedDirectivePattern = regexp.MustCompile(`(?m)^\s*--\s*@embed:\s*(.+?)\s*$`)
embedAttrPattern = regexp.MustCompile(`([a-zA-Z_][a-zA-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)`)
embedVarPattern = regexp.MustCompile(`^:[a-zA-Z_][a-zA-Z0-9_]*$`)
)
// ProcessEmbedDirectives expands SQL comments in the form:
//
// -- @embed: path=... var=:... mode=text|base64
//
// Paths are resolved relative to sqlPath. Text mode embeds a quoted UTF-8 SQL
// string literal. Base64 mode embeds a quoted base64 literal suitable for
// decode(:var, 'base64').
func ProcessEmbedDirectives(sqlPath, sql string) (string, error) {
return ProcessEmbedDirectivesWithOptions(sqlPath, sql, EmbedOptions{})
}
func embedDirectives(sql string) (result string, directives []string) {
// Only standalone line comments are directives, and only in SQL code.
result = sqllex.Walk(sql, nil, func(comment string) string {
if match := embedDirectivePattern.FindStringSubmatch(comment); match != nil {
directives = append(directives, match[1])
return ""
}
return comment
})
return result, directives
}
func HasEmbedDirectives(sql string) bool {
_, directives := embedDirectives(sql)
return len(directives) > 0
}
func ProcessEmbedDirectivesWithOptions(sqlPath, sql string, options EmbedOptions) (string, error) {
result, directives := embedDirectives(sql)
if len(directives) == 0 {
return sql, nil
}
if sqlPath == "" {
return "", fmt.Errorf("sql path is required for embed directives")
}
assetRoot := options.AssetRoot
if assetRoot == "" {
assetRoot = filepath.Dir(sqlPath)
}
absRoot, err := filepath.Abs(assetRoot)
if err != nil {
return "", err
}
absSQL, err := filepath.Abs(sqlPath)
if err != nil {
return "", err
}
scriptRel, err := filepath.Rel(absRoot, absSQL)
if err != nil {
return "", err
}
if _, err := safefile.Relative(filepath.ToSlash(scriptRel)); err != nil {
return "", fmt.Errorf("SQL source path is outside asset root: %w", err)
}
root, err := openAssetRoot(absRoot)
if err != nil {
return "", err
}
defer root.Close()
limit := options.MaxFileBytes
if limit == 0 {
limit = DefaultMaxAssetBytes
}
literals := map[string]string{}
used := map[string]bool{}
for i, directive := range directives {
literal, placeholder, err := embedDirectiveLiteral(root, filepath.Dir(scriptRel), limit, sqlPath, directive, i+1)
if err != nil {
return "", err
}
if _, exists := literals[placeholder]; exists {
return "", fmt.Errorf("duplicate embed placeholder %s", placeholder)
}
literals[placeholder] = literal
}
result = sqllex.Walk(result, func(name string) string {
if literal, ok := literals[name]; ok {
used[name] = true
return literal
}
return name
}, nil)
for name := range literals {
if !used[name] {
return "", fmt.Errorf("%s: embed placeholder %s not found in executable SQL", sqlPath, name)
}
}
return result, nil
}
func embedDirectiveLiteral(root *os.Root, scriptDir string, maxBytes int64, sqlPath, raw string, directiveNumber int) (literal, placeholder string, err error) {
attrs, err := parseEmbedAttrs(raw)
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
}
pathValue := attrs["path"]
varValue := attrs["var"]
modeValue := attrs["mode"]
if pathValue == "" {
return "", "", fmt.Errorf("%s embed directive %d: missing path", sqlPath, directiveNumber)
}
if !embedVarPattern.MatchString(varValue) {
return "", "", fmt.Errorf("%s embed directive %d: var must be a named placeholder like :asset", sqlPath, directiveNumber)
}
if modeValue != "text" && modeValue != "base64" {
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
}
relative, err := safefile.Relative(pathValue)
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
}
resolved := filepath.Join(scriptDir, relative)
data, err := safefile.Read(root, resolved, maxBytes)
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: reading %s: %w", sqlPath, directiveNumber, resolved, err)
}
switch modeValue {
case "text":
if !utf8.Valid(data) {
return "", "", fmt.Errorf("%s embed directive %d: %s is not valid UTF-8", sqlPath, directiveNumber, resolved)
}
literal, err := sqlStringLiteral(string(data))
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
}
return literal, varValue, nil
case "base64":
literal, err := sqlStringLiteral(base64.StdEncoding.EncodeToString(data))
if err != nil {
return "", "", fmt.Errorf("%s embed directive %d: %w", sqlPath, directiveNumber, err)
}
return literal, varValue, nil
default:
return "", "", fmt.Errorf("%s embed directive %d: mode must be text or base64", sqlPath, directiveNumber)
}
}
func parseEmbedAttrs(raw string) (map[string]string, error) {
attrs := map[string]string{}
matches := embedAttrPattern.FindAllStringSubmatchIndex(raw, -1)
if len(matches) == 0 {
return nil, fmt.Errorf("expected path, var, and mode attributes")
}
lastEnd := 0
for _, match := range matches {
gap := strings.TrimSpace(raw[lastEnd:match[0]])
if gap != "" {
return nil, fmt.Errorf("invalid attribute syntax near %q", gap)
}
key := raw[match[2]:match[3]]
value := raw[match[4]:match[5]]
if _, exists := attrs[key]; exists {
return nil, fmt.Errorf("duplicate attribute %q", key)
}
unquoted, err := unquoteEmbedValue(value)
if err != nil {
return nil, fmt.Errorf("invalid %s value: %w", key, err)
}
attrs[key] = unquoted
lastEnd = match[1]
}
if tail := strings.TrimSpace(raw[lastEnd:]); tail != "" {
return nil, fmt.Errorf("invalid attribute syntax near %q", tail)
}
for key := range attrs {
if key != "path" && key != "var" && key != "mode" {
return nil, fmt.Errorf("unknown attribute %q", key)
}
}
return attrs, nil
}
func unquoteEmbedValue(value string) (string, error) {
if len(value) < 2 {
return value, nil
}
if value[0] == '"' {
return strconv.Unquote(value)
}
if value[0] == '\'' && value[len(value)-1] == '\'' {
return value[1 : len(value)-1], nil
}
return value, nil
}
func sqlStringLiteral(value string) (string, error) {
if strings.ContainsRune(value, '\x00') {
return "", fmt.Errorf("embedded text contains NUL byte")
}
prefix := ""
if strings.Contains(value, "\\") {
prefix = "E"
value = strings.ReplaceAll(value, "\\", "\\\\")
}
return prefix + "'" + strings.ReplaceAll(value, "'", "''") + "'", nil
}