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