209 lines
6.4 KiB
Go
209 lines
6.4 KiB
Go
// Package assetloader implements a native Go asset/file loader that binds
|
|
// local binary and text files as pgx query parameters during database seeding.
|
|
// Files are bound as actual []byte query parameters — never converted to SQL
|
|
// text literals — so binary data stays byte-exact and no escaping is needed.
|
|
//
|
|
// Manifests are small YAML files (assets.yaml) that describe, per file, the
|
|
// SQL call to invoke and the named placeholders for :bytes, :filename, and any
|
|
// static column values. Manifests live inside directories that follow the same
|
|
// {priority}_{sequence}_{name} naming convention used by the sqldir reader,
|
|
// so asset-loading steps can be interleaved with SQL scripts in a migrate-apply
|
|
// run.
|
|
package assetloader
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
|
|
)
|
|
|
|
// ManifestEntry describes a single file to load from an assets.yaml manifest.
|
|
type ManifestEntry struct {
|
|
// File is the path to the asset file, relative to the manifest directory.
|
|
File string `yaml:"file"`
|
|
// Call is the SQL statement to execute. Use :bytes for file content,
|
|
// :filename for the base name, and :param_name for static params.
|
|
Call string `yaml:"call"`
|
|
// Params holds optional static named parameters referenced in Call.
|
|
Params map[string]string `yaml:"params,omitempty"`
|
|
}
|
|
|
|
// Item combines a manifest entry with its ordering metadata and the resolved
|
|
// directory where the manifest and asset file reside.
|
|
type Item struct {
|
|
// AssetRoot is the trusted scan root, retained for validation at execution time.
|
|
AssetRoot string
|
|
MaxFileBytes int64
|
|
// Priority and Sequence come from the parent directory's naming pattern.
|
|
Priority int
|
|
Sequence uint
|
|
// DirName is the last path component of the manifest's directory.
|
|
DirName string
|
|
// Dir is the absolute path to the directory containing assets.yaml and files.
|
|
Dir string
|
|
// Entry is the parsed manifest entry.
|
|
Entry ManifestEntry
|
|
}
|
|
|
|
// dirPattern matches {priority}_{sequence}_{name} or {priority}-{sequence}-{name}
|
|
// directory names, e.g. "1_010_seed_templates" or "2-001-branding".
|
|
var dirPattern = regexp.MustCompile(`^(\d+)[_-](\d+)[_-](.+)$`)
|
|
|
|
// LoadManifest reads and parses the assets.yaml file in dir, returning
|
|
// the ordered list of manifest entries. Returns an error if assets.yaml is
|
|
// absent or contains invalid YAML.
|
|
func LoadManifest(dir string) ([]ManifestEntry, error) {
|
|
manifestPath := filepath.Join(dir, "assets.yaml")
|
|
root, err := openAssetRoot(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer root.Close()
|
|
data, err := safefile.Read(root, "assets.yaml", DefaultMaxAssetBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", manifestPath, err)
|
|
}
|
|
|
|
var entries []ManifestEntry
|
|
if err := yaml.Unmarshal(data, &entries); err != nil {
|
|
return nil, fmt.Errorf("parsing %s: %w", manifestPath, err)
|
|
}
|
|
|
|
return entries, nil
|
|
}
|
|
|
|
// ScanDir recursively walks baseDir, finds all assets.yaml manifests, resolves
|
|
// each file entry (skipping symlinks and path traversal), and returns the
|
|
// resulting Items sorted by (Priority, Sequence, DirName).
|
|
//
|
|
// Each manifest must reside in a directory whose name follows the
|
|
// {priority}_{sequence}_{name} pattern. Manifests in directories that do not
|
|
// follow this convention are assigned Priority=0, Sequence=0 and sorted last.
|
|
func ScanDir(baseDir string) ([]Item, error) {
|
|
absBase, err := filepath.Abs(baseDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolving base dir: %w", err)
|
|
}
|
|
|
|
var items []Item
|
|
|
|
err = filepath.WalkDir(absBase, func(path string, d os.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if d.Type()&os.ModeSymlink != 0 || d.Name() != "assets.yaml" {
|
|
return nil
|
|
}
|
|
|
|
manifestDir := filepath.Dir(path)
|
|
priority, sequence, dirName := parseDirName(filepath.Base(manifestDir))
|
|
|
|
entries, err := LoadManifest(manifestDir)
|
|
if err != nil {
|
|
return fmt.Errorf("loading manifest in %s: %w", manifestDir, err)
|
|
}
|
|
|
|
for _, entry := range entries {
|
|
if entry.File == "" || entry.Call == "" {
|
|
continue
|
|
}
|
|
|
|
// Resolve and validate the asset file path.
|
|
resolved, skip, err := resolveAssetPath(absBase, manifestDir, entry.File)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if skip {
|
|
continue
|
|
}
|
|
|
|
items = append(items, Item{
|
|
AssetRoot: absBase,
|
|
MaxFileBytes: DefaultMaxAssetBytes,
|
|
Priority: priority,
|
|
Sequence: sequence,
|
|
DirName: dirName,
|
|
Dir: manifestDir,
|
|
Entry: ManifestEntry{
|
|
File: resolved, // absolute path, safe to read
|
|
Call: entry.Call,
|
|
Params: entry.Params,
|
|
},
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].Priority != items[j].Priority {
|
|
return items[i].Priority < items[j].Priority
|
|
}
|
|
if items[i].Sequence != items[j].Sequence {
|
|
return items[i].Sequence < items[j].Sequence
|
|
}
|
|
return items[i].DirName < items[j].DirName
|
|
})
|
|
|
|
return items, nil
|
|
}
|
|
|
|
// parseDirName extracts (priority, sequence, name) from a directory name that
|
|
// follows the {priority}[_-]{sequence}[_-]{name} convention. Returns (0, 0, dir)
|
|
// when the name does not match.
|
|
func parseDirName(dir string) (priority int, sequence uint, name string) {
|
|
m := dirPattern.FindStringSubmatch(dir)
|
|
if m == nil {
|
|
return 0, 0, dir
|
|
}
|
|
p, _ := strconv.Atoi(m[1])
|
|
s, _ := strconv.ParseUint(m[2], 10, 64)
|
|
return p, uint(s), m[3]
|
|
}
|
|
|
|
// resolveAssetPath resolves a manifest-relative file path and checks that:
|
|
// - it does not escape the base directory (path traversal prevention)
|
|
// - none of its path components are symlinks
|
|
//
|
|
// Returns the absolute path, a skip flag (true when the entry should be silently
|
|
// dropped), and any hard error.
|
|
func resolveAssetPath(absBase, manifestDir, file string) (absPath string, skip bool, err error) {
|
|
if _, err := safefile.Relative(file); err != nil {
|
|
return "", true, nil
|
|
}
|
|
joined := filepath.Join(manifestDir, file)
|
|
rel, err := filepath.Rel(absBase, joined)
|
|
if err != nil {
|
|
return "", true, nil
|
|
}
|
|
root, err := openAssetRoot(absBase)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
defer root.Close()
|
|
if err := safefile.Check(root, rel); err != nil {
|
|
return "", true, nil
|
|
}
|
|
info, err := root.Stat(rel)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return "", true, nil
|
|
}
|
|
if info.Size() > DefaultMaxAssetBytes {
|
|
return "", false, fmt.Errorf("asset %q exceeds %d byte limit", rel, DefaultMaxAssetBytes)
|
|
}
|
|
return joined, false, nil
|
|
}
|