255 lines
8.2 KiB
Go
255 lines
8.2 KiB
Go
package assetloader
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestProcessEmbedDirectives_TextLiteralEscapesQuotes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("Line 1\nIt's fine"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
|
-- @embed: path=body.txt var=:body mode=text
|
|
INSERT INTO notes (body) VALUES (:body);
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
|
}
|
|
if !strings.Contains(got, "VALUES ('Line 1\nIt''s fine');") {
|
|
t.Fatalf("embedded SQL did not contain escaped text literal:\n%s", got)
|
|
}
|
|
if strings.Contains(got, "VALUES (:body);") {
|
|
t.Fatalf("placeholder was not replaced:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessEmbedDirectives_Base64Literal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
|
binary := []byte{0x00, 0xff, 0x10, 0x20}
|
|
if err := os.WriteFile(filepath.Join(dir, "blob.bin"), binary, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
|
-- @embed: path=blob.bin var=:payload mode=base64
|
|
INSERT INTO files (payload) VALUES (decode(:payload, 'base64')::bytea);
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
|
}
|
|
want := "decode('" + base64.StdEncoding.EncodeToString(binary) + "', 'base64')::bytea"
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("embedded SQL did not contain base64 literal %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
func TestProcessEmbedDirectives_RelativeToSQLFile(t *testing.T) {
|
|
root := t.TempDir()
|
|
sqlDir := filepath.Join(root, "nested", "seed")
|
|
if err := os.MkdirAll(filepath.Join(sqlDir, "assets"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sqlPath := filepath.Join(sqlDir, "1_001_seed.sql")
|
|
if err := os.WriteFile(filepath.Join(sqlDir, "assets", "body.txt"), []byte("relative body"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
|
-- @embed: path=assets/body.txt var=:body mode=text
|
|
SELECT :body;
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
|
}
|
|
if !strings.Contains(got, "SELECT 'relative body';") {
|
|
t.Fatalf("path was not resolved relative to SQL file:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessEmbedDirectives_InvalidDirectiveAndFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("ok"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "binary.txt"), []byte{0xff, 0xfe}, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
sql string
|
|
}{
|
|
{
|
|
name: "missing mode",
|
|
sql: "-- @embed: path=body.txt var=:body\nSELECT :body;",
|
|
},
|
|
{
|
|
name: "invalid var",
|
|
sql: "-- @embed: path=body.txt var=body mode=text\nSELECT :body;",
|
|
},
|
|
{
|
|
name: "missing file",
|
|
sql: "-- @embed: path=missing.txt var=:body mode=text\nSELECT :body;",
|
|
},
|
|
{
|
|
name: "invalid utf8 text",
|
|
sql: "-- @embed: path=binary.txt var=:body mode=text\nSELECT :body;",
|
|
},
|
|
{
|
|
name: "placeholder not found",
|
|
sql: "-- @embed: path=body.txt var=:body mode=text\nSELECT 1;",
|
|
},
|
|
{
|
|
name: "unknown attribute",
|
|
sql: "-- @embed: path=body.txt var=:body mode=text extra=yes\nSELECT :body;",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if _, err := ProcessEmbedDirectives(sqlPath, tt.sql); err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessEmbedDirectives_DoesNotReplacePlaceholderPrefix(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sqlPath := filepath.Join(dir, "1_001_seed.sql")
|
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("ok"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := ProcessEmbedDirectives(sqlPath, `
|
|
-- @embed: path=body.txt var=:body mode=text
|
|
SELECT :body, :body_extra;
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("ProcessEmbedDirectives failed: %v", err)
|
|
}
|
|
if !strings.Contains(got, "SELECT 'ok', :body_extra;") {
|
|
t.Fatalf("placeholder boundary was not respected:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestEmbedRejectsTraversalAbsolutePathsAndSymlinks(t *testing.T) {
|
|
root := t.TempDir()
|
|
scriptDir := filepath.Join(root, "scripts")
|
|
if err := os.Mkdir(scriptDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
secret := filepath.Join(root, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("never print this secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, path := range []string{"../secret.txt", secret, `..\secret.txt`, `C:\secret.txt`, `\\server\secret.txt`} {
|
|
sql := "-- @embed: path='" + path + "' var=:body mode=text\nSELECT :body;"
|
|
got, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), sql)
|
|
if err == nil || got != "" {
|
|
t.Fatalf("unsafe embed path accepted %q: %q %v", path, got, err)
|
|
}
|
|
if strings.Contains(err.Error(), "never print this secret") {
|
|
t.Fatal("diagnostic leaked asset bytes")
|
|
}
|
|
}
|
|
if err := os.Symlink(root, filepath.Join(scriptDir, "link")); err != nil {
|
|
t.Skip(err)
|
|
}
|
|
if _, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), "-- @embed: path=link/secret.txt var=:body mode=text\nSELECT :body;"); err == nil {
|
|
t.Fatal("symlink parent must be rejected")
|
|
}
|
|
if err := os.Symlink(secret, filepath.Join(scriptDir, "leaf.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ProcessEmbedDirectives(filepath.Join(scriptDir, "seed.sql"), "-- @embed: path=leaf.txt var=:body mode=text\nSELECT :body;"); err == nil {
|
|
t.Fatal("symlink asset must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestEmbedExplicitRootAndSizeLimit(t *testing.T) {
|
|
root := t.TempDir()
|
|
dir := filepath.Join(root, "scripts")
|
|
if err := os.Mkdir(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "body.txt"), []byte("12345"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sql := "-- @embed: path=body.txt var=:body mode=text\nSELECT :body;"
|
|
path := filepath.Join(dir, "seed.sql")
|
|
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: root, MaxFileBytes: 4}); err == nil || !strings.Contains(err.Error(), "byte limit") {
|
|
t.Fatalf("expected size limit error: %v", err)
|
|
}
|
|
if got, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: root, MaxFileBytes: 5}); err != nil || !strings.Contains(got, "SELECT '12345';") {
|
|
t.Fatalf("valid bounded embed: %q %v", got, err)
|
|
}
|
|
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{AssetRoot: t.TempDir()}); err == nil {
|
|
t.Fatal("source path outside explicit root must fail")
|
|
}
|
|
if _, err := ProcessEmbedDirectivesWithOptions(path, sql, EmbedOptions{MaxFileBytes: -1}); err == nil {
|
|
t.Fatal("negative limits must fail")
|
|
}
|
|
}
|
|
|
|
func TestManifestReadRechecksContainmentAndSize(t *testing.T) {
|
|
root, external := t.TempDir(), t.TempDir()
|
|
file := filepath.Join(root, "body.txt")
|
|
if err := os.WriteFile(file, []byte("valid"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
item := Item{AssetRoot: root, MaxFileBytes: 4, Entry: ManifestEntry{File: file}}
|
|
if _, err := readItemAsset(item); err == nil {
|
|
t.Fatal("manifest size bound must apply at execution")
|
|
}
|
|
item.MaxFileBytes = 5
|
|
if got, err := readItemAsset(item); err != nil || string(got) != "valid" {
|
|
t.Fatalf("valid manifest asset: %q %v", got, err)
|
|
}
|
|
secret := filepath.Join(external, "secret.txt")
|
|
if err := os.WriteFile(secret, []byte("secret"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Remove(file); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(secret, file); err != nil {
|
|
t.Skip(err)
|
|
}
|
|
if _, err := readItemAsset(item); err == nil {
|
|
t.Fatal("replacement symlink after scanning must fail")
|
|
}
|
|
item.Entry.File = secret
|
|
if _, err := readItemAsset(item); err == nil {
|
|
t.Fatal("forged asset path must fail")
|
|
}
|
|
}
|
|
|
|
func TestEmbedDefaultLimitRejectsOversizedFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file, err := os.Create(filepath.Join(dir, "large.bin"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := file.Truncate(DefaultMaxAssetBytes + 1); err != nil {
|
|
file.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ProcessEmbedDirectives(filepath.Join(dir, "seed.sql"), "-- @embed: path=large.bin var=:payload mode=base64\nSELECT :payload;")
|
|
if err == nil || !strings.Contains(err.Error(), "byte limit") {
|
|
t.Fatalf("expected default size bound: %v", err)
|
|
}
|
|
}
|