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>
342 lines
8.7 KiB
Go
342 lines
8.7 KiB
Go
package assetloader_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"git.warky.dev/wdevs/relspecgo/pkg/assetloader"
|
|
)
|
|
|
|
func TestLoadManifest_ValidList(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "assets.yaml", `
|
|
- file: hello.txt
|
|
call: INSERT INTO files (name, data) VALUES (:filename, :bytes)
|
|
- file: logo.png
|
|
call: UPDATE branding SET logo = :bytes WHERE id = 1
|
|
`)
|
|
writeFile(t, dir, "hello.txt", "hello world")
|
|
writeFile(t, dir, "logo.png", "\x89PNG\r\n\x1a\n")
|
|
|
|
m, err := assetloader.LoadManifest(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest failed: %v", err)
|
|
}
|
|
if len(m) != 2 {
|
|
t.Fatalf("expected 2 entries, got %d", len(m))
|
|
}
|
|
if m[0].File != "hello.txt" {
|
|
t.Errorf("entry 0 file: got %q, want %q", m[0].File, "hello.txt")
|
|
}
|
|
if m[1].File != "logo.png" {
|
|
t.Errorf("entry 1 file: got %q, want %q", m[1].File, "logo.png")
|
|
}
|
|
}
|
|
|
|
func TestLoadManifest_WithStaticParams(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "assets.yaml", `
|
|
- file: template.md
|
|
call: INSERT INTO templates (owner_id, name, data) VALUES (:owner_id, :filename, :bytes)
|
|
params:
|
|
owner_id: "42"
|
|
`)
|
|
writeFile(t, dir, "template.md", "# Template")
|
|
|
|
m, err := assetloader.LoadManifest(dir)
|
|
if err != nil {
|
|
t.Fatalf("LoadManifest failed: %v", err)
|
|
}
|
|
if len(m) != 1 {
|
|
t.Fatalf("expected 1 entry, got %d", len(m))
|
|
}
|
|
if m[0].Params["owner_id"] != "42" {
|
|
t.Errorf("static param owner_id: got %q, want %q", m[0].Params["owner_id"], "42")
|
|
}
|
|
}
|
|
|
|
func TestLoadManifest_MissingFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// No assets.yaml present
|
|
_, err := assetloader.LoadManifest(dir)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing assets.yaml, got nil")
|
|
}
|
|
}
|
|
|
|
func TestLoadManifest_InvalidYAML(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "assets.yaml", `{not: [valid yaml`)
|
|
_, err := assetloader.LoadManifest(dir)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid YAML, got nil")
|
|
}
|
|
}
|
|
|
|
func TestScanDir_FindsManifests(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
// Directory named with priority-sequence pattern
|
|
dir1 := filepath.Join(root, "1_010_seed_templates")
|
|
if err := os.MkdirAll(dir1, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, dir1, "assets.yaml", `
|
|
- file: a.txt
|
|
call: INSERT INTO t (data) VALUES (:bytes)
|
|
`)
|
|
writeFile(t, dir1, "a.txt", "aaa")
|
|
|
|
dir2 := filepath.Join(root, "2_001_branding")
|
|
if err := os.MkdirAll(dir2, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, dir2, "assets.yaml", `
|
|
- file: logo.png
|
|
call: UPDATE branding SET logo = :bytes
|
|
`)
|
|
writeFile(t, dir2, "logo.png", "PNG")
|
|
|
|
items, err := assetloader.ScanDir(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanDir failed: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Fatalf("expected 2 items, got %d", len(items))
|
|
}
|
|
// Should be ordered by priority then sequence
|
|
if items[0].Priority != 1 || items[0].Sequence != 10 {
|
|
t.Errorf("item[0]: got priority=%d seq=%d, want 1,10", items[0].Priority, items[0].Sequence)
|
|
}
|
|
if items[1].Priority != 2 || items[1].Sequence != 1 {
|
|
t.Errorf("item[1]: got priority=%d seq=%d, want 2,1", items[1].Priority, items[1].Sequence)
|
|
}
|
|
}
|
|
|
|
func TestScanDir_OrdersByPriorityThenSequence(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
for _, d := range []string{"2_002_b", "1_001_a", "2_001_c", "1_002_d"} {
|
|
dir := filepath.Join(root, d)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, dir, "assets.yaml", `
|
|
- file: x.txt
|
|
call: SELECT :bytes
|
|
`)
|
|
writeFile(t, dir, "x.txt", "x")
|
|
}
|
|
|
|
items, err := assetloader.ScanDir(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanDir failed: %v", err)
|
|
}
|
|
if len(items) != 4 {
|
|
t.Fatalf("expected 4 items, got %d", len(items))
|
|
}
|
|
|
|
type ps struct{ p int; s uint }
|
|
want := []ps{{1, 1}, {1, 2}, {2, 1}, {2, 2}}
|
|
for i, w := range want {
|
|
got := ps{items[i].Priority, items[i].Sequence}
|
|
if got != w {
|
|
t.Errorf("items[%d]: got {%d,%d}, want {%d,%d}", i, got.p, got.s, w.p, w.s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanDir_SkipsSymlinks(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
dir1 := filepath.Join(root, "1_001_real")
|
|
if err := os.MkdirAll(dir1, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, dir1, "assets.yaml", `
|
|
- file: a.txt
|
|
call: SELECT :bytes
|
|
`)
|
|
writeFile(t, dir1, "a.txt", "real")
|
|
|
|
// Symlink to an asset file - should be skipped during file read
|
|
realFile := filepath.Join(root, "real.txt")
|
|
writeFile(t, root, "real.txt", "symlink target")
|
|
symlink := filepath.Join(dir1, "link.txt")
|
|
if err := os.Symlink(realFile, symlink); err != nil {
|
|
t.Skip("symlinks not supported:", err)
|
|
}
|
|
|
|
// Add a manifest entry that references the symlink
|
|
writeFile(t, dir1, "assets.yaml", `
|
|
- file: a.txt
|
|
call: SELECT :bytes
|
|
- file: link.txt
|
|
call: SELECT :bytes
|
|
`)
|
|
|
|
items, err := assetloader.ScanDir(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanDir failed: %v", err)
|
|
}
|
|
// The symlink entry should be skipped; only a.txt should remain
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1 item after symlink skip, got %d", len(items))
|
|
}
|
|
if filepath.Base(items[0].Entry.File) != "a.txt" {
|
|
t.Errorf("expected non-symlink entry, got %q", items[0].Entry.File)
|
|
}
|
|
}
|
|
|
|
func TestScanDir_RejectsPathTraversal(t *testing.T) {
|
|
root := t.TempDir()
|
|
|
|
dir1 := filepath.Join(root, "1_001_evil")
|
|
if err := os.MkdirAll(dir1, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeFile(t, dir1, "assets.yaml", `
|
|
- file: ../../etc/passwd
|
|
call: SELECT :bytes
|
|
`)
|
|
|
|
items, err := assetloader.ScanDir(root)
|
|
if err != nil {
|
|
t.Fatalf("ScanDir failed: %v", err)
|
|
}
|
|
// Path traversal entry should be skipped
|
|
if len(items) != 0 {
|
|
t.Fatalf("expected 0 items after path traversal rejection, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_BasicPlaceholders(t *testing.T) {
|
|
sql, args, err := assetloader.BuildQuery(
|
|
"INSERT INTO t (name, data) VALUES (:filename, :bytes)",
|
|
[]byte("hello"),
|
|
"hello.txt",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BuildQuery failed: %v", err)
|
|
}
|
|
if sql != "INSERT INTO t (name, data) VALUES ($1, $2)" {
|
|
t.Errorf("unexpected SQL: %s", sql)
|
|
}
|
|
if len(args) != 2 {
|
|
t.Fatalf("expected 2 args, got %d", len(args))
|
|
}
|
|
if string(args[0].(string)) != "hello.txt" {
|
|
t.Errorf("args[0]: got %q, want %q", args[0], "hello.txt")
|
|
}
|
|
if string(args[1].([]byte)) != "hello" {
|
|
t.Errorf("args[1]: got %v, want %v", args[1], []byte("hello"))
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_StaticParams(t *testing.T) {
|
|
sql, args, err := assetloader.BuildQuery(
|
|
"INSERT INTO t (owner, name, data) VALUES (:owner_id, :filename, :bytes)",
|
|
[]byte("data"),
|
|
"file.bin",
|
|
map[string]string{"owner_id": "99"},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BuildQuery failed: %v", err)
|
|
}
|
|
if sql != "INSERT INTO t (owner, name, data) VALUES ($1, $2, $3)" {
|
|
t.Errorf("unexpected SQL: %s", sql)
|
|
}
|
|
if len(args) != 3 {
|
|
t.Fatalf("expected 3 args, got %d: %v", len(args), args)
|
|
}
|
|
if args[0].(string) != "99" {
|
|
t.Errorf("args[0]: got %q, want %q", args[0], "99")
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_RepeatedPlaceholder(t *testing.T) {
|
|
sql, args, err := assetloader.BuildQuery(
|
|
"SELECT length(:bytes), encode(:bytes, 'base64')",
|
|
[]byte("abc"),
|
|
"f.bin",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BuildQuery failed: %v", err)
|
|
}
|
|
// :bytes appears twice but maps to same $1
|
|
if sql != "SELECT length($1), encode($1, 'base64')" {
|
|
t.Errorf("unexpected SQL: %s", sql)
|
|
}
|
|
if len(args) != 1 {
|
|
t.Fatalf("expected 1 arg, got %d", len(args))
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_PostgresCastNotMatched(t *testing.T) {
|
|
// ::text should NOT be treated as a placeholder
|
|
sql, args, err := assetloader.BuildQuery(
|
|
"SELECT :bytes::text, :filename",
|
|
[]byte("data"),
|
|
"f.txt",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BuildQuery failed: %v", err)
|
|
}
|
|
if sql != "SELECT $1::text, $2" {
|
|
t.Errorf("unexpected SQL: %s", sql)
|
|
}
|
|
if len(args) != 2 {
|
|
t.Fatalf("expected 2 args, got %d", len(args))
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_UnknownPlaceholder(t *testing.T) {
|
|
_, _, err := assetloader.BuildQuery(
|
|
"SELECT :unknown_param",
|
|
[]byte("data"),
|
|
"f.txt",
|
|
nil,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown placeholder, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBuildQuery_BinaryFileByteExact(t *testing.T) {
|
|
// Binary data with null bytes, high bytes - must pass through unchanged
|
|
binary := []byte{0x00, 0xFF, 0x80, 0x01, 0xFE}
|
|
_, args, err := assetloader.BuildQuery(
|
|
"INSERT INTO blobs (data) VALUES (:bytes)",
|
|
binary,
|
|
"blob.bin",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("BuildQuery failed: %v", err)
|
|
}
|
|
if len(args) != 1 {
|
|
t.Fatalf("expected 1 arg")
|
|
}
|
|
got := args[0].([]byte)
|
|
if len(got) != len(binary) {
|
|
t.Fatalf("byte count: got %d, want %d", len(got), len(binary))
|
|
}
|
|
for i, b := range binary {
|
|
if got[i] != b {
|
|
t.Errorf("byte[%d]: got 0x%02x, want 0x%02x", i, got[i], b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeFile is a test helper that writes content to a file.
|
|
func writeFile(t *testing.T, dir, name, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
|
t.Fatalf("writeFile %s: %v", name, err)
|
|
}
|
|
}
|