60c5cc40b2
Implements a new `relspec assets` command (list/execute subcommands) that
loads local binary and text asset files into PostgreSQL by binding file bytes
as native pgx query parameters — never as SQL text literals — so binary data
stays byte-exact with no escaping overhead.
Key design points:
- YAML manifest (assets.yaml) colocated with files describes each entry:
file path, SQL call with :bytes/:filename/:param named placeholders, and
optional static params map.
- Placeholder substitution converts :name to positional $N params; PostgreSQL
::cast syntax is protected before substitution to avoid false matches.
- Directory scan follows the existing {priority}_{sequence}_{name} naming
convention, enabling asset-loading steps to be correctly interleaved with
relspec scripts execute in a migrate-apply pipeline.
- Symlink components and path traversal (../) are silently skipped to prevent
directory escape attacks.
- 14 unit tests cover manifest loading, directory scanning, ordering, symlink
skipping, path traversal rejection, placeholder substitution edge cases
(repeated, cast protection, binary byte-exact, unknown).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
6.2 KiB
Go
201 lines
6.2 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"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// 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 {
|
|
// 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")
|
|
data, err := os.ReadFile(manifestPath)
|
|
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.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{
|
|
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) {
|
|
// Clean and join before any symlink resolution so we can detect traversal.
|
|
joined := filepath.Join(manifestDir, filepath.Clean(file))
|
|
|
|
// Ensure the cleaned path is still inside absBase.
|
|
rel, err := filepath.Rel(absBase, joined)
|
|
if err != nil || strings.HasPrefix(rel, "..") {
|
|
// Path escapes the base directory; skip silently.
|
|
return "", true, nil
|
|
}
|
|
|
|
// Walk each component to detect symlinks.
|
|
parts := strings.Split(rel, string(filepath.Separator))
|
|
current := absBase
|
|
for _, part := range parts {
|
|
current = filepath.Join(current, part)
|
|
info, statErr := os.Lstat(current)
|
|
if statErr != nil {
|
|
// File doesn't exist; skip.
|
|
return "", true, nil
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
// Symlink in path; skip silently.
|
|
return "", true, nil
|
|
}
|
|
}
|
|
|
|
return joined, false, nil
|
|
}
|