fix: contain generated files and assets and harden SQL rendering

This commit is contained in:
2026-10-12 20:59:46 +02:00
parent 2fa48335d3
commit dd97413b8e
68 changed files with 2328 additions and 798 deletions
+14 -7
View File
@@ -168,15 +168,22 @@ relspec templ --from json --from-path schema.json --template test.tmpl
## Multi-file Output
For multi-file modes, the writer:
For multi-file modes, `OutputPath` is always the output directory, including when
it does not yet exist or has no trailing slash. An empty path uses the current
directory. Database mode continues to treat `OutputPath` as one explicit file.
1. Iterates through items (schemas/scripts/tables)
2. Creates `TemplateData` for each item
3. Executes template with item data
4. Generates filename using `filename_pattern` template
5. Writes output to generated filename
The writer renders and validates all filenames and contents before writing. Names
must be relative, may contain safe subdirectories, and cannot contain traversal,
absolute/drive/UNC paths, backslashes, Windows device names, alternate streams,
control characters, or trailing dots/spaces. Duplicate filenames (including case
variants) and file/directory collisions fail before any artifact is replaced.
Output directory is created automatically if it doesn't exist.
Output directories are created automatically. Existing symlink roots, parents, and
files are rejected. Writes and renames use `os.Root` to retain containment if paths
change during generation. Each file is staged exclusively, closed successfully,
and renamed into place; existing permissions are preserved on replacement.
Multi-file publication is per file, so an I/O error partway through finalization can
still leave a mixed generation (the broader audit A16 staging/manifest work remains).
## Filename Pattern Execution
+119 -97
View File
@@ -2,6 +2,9 @@ package template
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
@@ -9,6 +12,7 @@ import (
"text/template"
"git.warky.dev/wdevs/relspecgo/pkg/models"
"git.warky.dev/wdevs/relspecgo/pkg/safefile"
"git.warky.dev/wdevs/relspecgo/pkg/writers"
)
@@ -125,96 +129,132 @@ func (w *Writer) executeDatabaseMode(db *models.Database) error {
return w.writeOutput(output, w.options.OutputPath)
}
// executeSchemaMode executes the template once per schema
// Multi-file generation renders and validates every artifact before touching output.
func (w *Writer) executeSchemaMode(db *models.Database) error {
var data []*TemplateData
for _, schema := range db.Schemas {
data := NewSchemaData(schema, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for schema %s: %w", schema.Name, err)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for schema %s: %w", schema.Name, err)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for schema %s: %w", schema.Name, err)
}
data = append(data, NewSchemaData(schema, w.options.Metadata))
}
return nil
return w.executeFiles(data)
}
// executeDomainMode executes the template once per domain
func (w *Writer) executeDomainMode(db *models.Database) error {
var data []*TemplateData
for _, domain := range db.Domains {
data := NewDomainData(domain, db, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for domain %s: %w", domain.Name, err)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for domain %s: %w", domain.Name, err)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for domain %s: %w", domain.Name, err)
}
data = append(data, NewDomainData(domain, db, w.options.Metadata))
}
return nil
return w.executeFiles(data)
}
// executeScriptMode executes the template once per script
func (w *Writer) executeScriptMode(db *models.Database) error {
var data []*TemplateData
for _, schema := range db.Schemas {
for _, script := range schema.Scripts {
data := NewScriptData(script, schema, db, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for script %s: %w", script.Name, err)
}
data = append(data, NewScriptData(script, schema, db, w.options.Metadata))
}
}
return w.executeFiles(data)
}
func (w *Writer) executeTableMode(db *models.Database) error {
var data []*TemplateData
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
data = append(data, NewTableData(table, schema, db, w.options.Metadata))
}
}
return w.executeFiles(data)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for script %s: %w", script.Name, err)
}
type artifact struct{ name, content string }
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for script %s: %w", script.Name, err)
func (w *Writer) outputRoot() string {
if w.options.OutputPath == "" {
return "."
}
return w.options.OutputPath
}
func (w *Writer) executeFiles(data []*TemplateData) error {
var files []artifact
seen := make(map[string]bool)
for _, item := range data {
name, err := w.renderFilename(item)
if err != nil {
return err
}
key := strings.ToLower(filepath.ToSlash(name))
if seen[key] {
return fmt.Errorf("generated filename collision: %q", name)
}
seen[key] = true
content, err := w.executeTemplate(item)
if err != nil {
return err
}
files = append(files, artifact{name, content})
}
// Reject file/directory conflicts before creating any output.
for name := range seen {
for parent := filepath.ToSlash(filepath.Dir(name)); parent != "."; parent = filepath.ToSlash(filepath.Dir(parent)) {
if seen[parent] {
return fmt.Errorf("generated file/directory collision: %q", name)
}
}
}
if len(files) == 0 {
return nil
}
rootPath := w.outputRoot()
if err := safefile.CheckRoot(rootPath); err != nil {
return err
}
if err := os.MkdirAll(rootPath, 0o755); err != nil {
return err
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return err
}
defer root.Close()
// Preflight every destination, including existing directory and symlink conflicts.
for _, file := range files {
if err := safefile.Check(root, file.name); err != nil {
return err
}
}
for _, file := range files {
if err := w.writeArtifact(root, file); err != nil {
return fmt.Errorf("write generated file %q: %w", file.name, err)
}
}
return nil
}
// executeTableMode executes the template once per table
func (w *Writer) executeTableMode(db *models.Database) error {
for _, schema := range db.Schemas {
for _, table := range schema.Tables {
data := NewTableData(table, schema, db, w.options.Metadata)
output, err := w.executeTemplate(data)
if err != nil {
return fmt.Errorf("failed to execute template for table %s.%s: %w", schema.Name, table.Name, err)
}
filename, err := w.generateFilename(data)
if err != nil {
return fmt.Errorf("failed to generate filename for table %s.%s: %w", schema.Name, table.Name, err)
}
if err := w.writeOutput(output, filename); err != nil {
return fmt.Errorf("failed to write output for table %s.%s: %w", schema.Name, table.Name, err)
}
}
func (w *Writer) writeArtifact(root *os.Root, file artifact) error {
if err := root.MkdirAll(filepath.Dir(file.name), 0o755); err != nil {
return err
}
return nil
// Exclusive unpredictable staging avoids truncating a destination (or a link target).
var token [16]byte
if _, err := rand.Read(token[:]); err != nil {
return err
}
temporary := ".relspec-" + hex.EncodeToString(token[:]) + ".tmp"
mode := os.FileMode(0o644)
if info, err := root.Lstat(file.name); err == nil && info.Mode().IsRegular() {
mode = info.Mode().Perm()
}
staged, err := root.OpenFile(temporary, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return err
}
defer root.Remove(temporary)
_, writeErr := staged.WriteString(file.content)
if err := errors.Join(writeErr, staged.Close()); err != nil {
return err
}
if err := safefile.Check(root, file.name); err != nil {
return err
}
return root.Rename(temporary, file.name)
}
// loadTemplate loads and parses the template file
@@ -245,43 +285,25 @@ func (w *Writer) executeTemplate(data *TemplateData) (string, error) {
return buf.String(), nil
}
// generateFilename generates a filename from the filename pattern
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
// Parse filename pattern as a template
// renderFilename returns a validated path relative to the explicit output root.
func (w *Writer) renderFilename(data *TemplateData) (string, error) {
tmpl, err := template.New("filename").Funcs(w.funcMap).Parse(w.filenamePattern)
if err != nil {
return "", fmt.Errorf("invalid filename pattern: %w", err)
}
// Execute filename template
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("failed to generate filename: %w", err)
}
return safefile.Relative(buf.String())
}
filename := buf.String()
// If output path is a directory, join with generated filename
if w.options.OutputPath != "" {
// Check if output path is a directory
info, err := os.Stat(w.options.OutputPath)
if err == nil && info.IsDir() {
filename = filepath.Join(w.options.OutputPath, filename)
} else {
// If it doesn't exist, check if it looks like a directory (ends with /)
if strings.HasSuffix(w.options.OutputPath, string(filepath.Separator)) {
filename = filepath.Join(w.options.OutputPath, filename)
} else {
// Use output path as base directory
dir := filepath.Dir(w.options.OutputPath)
if dir != "." {
filename = filepath.Join(dir, filename)
}
}
}
func (w *Writer) generateFilename(data *TemplateData) (string, error) {
name, err := w.renderFilename(data)
if err != nil {
return "", err
}
return filename, nil
return filepath.Join(w.outputRoot(), name), nil
}
// writeOutput writes the output to a file or stdout
+122 -2
View File
@@ -180,8 +180,8 @@ func TestWriterGenerateFilenameOutputPathForms(t *testing.T) {
{"no output path", "", "users.txt"},
{"existing dir", dir, filepath.Join(dir, "users.txt")},
{"trailing separator", filepath.Join(dir, "new") + string(filepath.Separator), filepath.Join(dir, "new", "users.txt")},
{"file path uses its dir", filepath.Join(dir, "x.out"), filepath.Join(dir, "users.txt")},
{"bare file name", "x.out", "users.txt"},
{"nonexistent path is root", filepath.Join(dir, "x.out"), filepath.Join(dir, "x.out", "users.txt")},
{"bare path is root", "x.out", filepath.Join("x.out", "users.txt")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -217,3 +217,123 @@ func TestWriterWriteSchemaAndTable(t *testing.T) {
t.Errorf("WriteTable: %q", data)
}
}
func TestMultiFileRejectsUnsafeFilenamesBeforeWriting(t *testing.T) {
for _, name := range []string{"../escape", "/absolute", `..\escape`, `C:\escape`, `\\server\share\file`, "a/../escape", "a//escape", "CON", "nul.txt", "COM¹.txt", "file:stream", "trailing.", "trailing ", "line\nbreak"} {
t.Run(name, func(t *testing.T) {
out := filepath.Join(t.TempDir(), "new-output")
w, err := newTestWriter(t, "content", "schema", "{{.Name}}", out)
if err != nil {
t.Fatal(err)
}
db := models.InitDatabase("unsafe")
db.Schemas = []*models.Schema{models.InitSchema("valid"), models.InitSchema(name)}
if err := w.WriteDatabase(db); err == nil {
t.Fatal("expected rejected filename")
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatalf("validation failure must not create output: %v", err)
}
})
}
}
func TestMultiFileCollisionsDoNotOverwrite(t *testing.T) {
for _, names := range [][]string{{"same.txt", "same.txt"}, {"SAME.txt", "same.txt"}, {"file", "file/child.txt"}} {
out := t.TempDir()
seed := filepath.Join(out, "same.txt")
if err := os.WriteFile(seed, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
w, err := newTestWriter(t, "replacement", "schema", "{{.Name}}", out)
if err != nil {
t.Fatal(err)
}
db := models.InitDatabase("collision")
for _, name := range names {
db.Schemas = append(db.Schemas, models.InitSchema(name))
}
if err := w.WriteDatabase(db); err == nil || !strings.Contains(err.Error(), "collision") {
t.Fatalf("expected collision error: %v", err)
}
got, err := os.ReadFile(seed)
if err != nil || string(got) != "original" {
t.Fatalf("existing output changed: %q %v", got, err)
}
}
}
func TestMultiFileRejectsSymlinksBeforeAnyWrites(t *testing.T) {
for _, kind := range []string{"parent", "leaf", "root"} {
t.Run(kind, func(t *testing.T) {
out, external := t.TempDir(), t.TempDir()
secret := filepath.Join(external, "secret.txt")
if err := os.WriteFile(secret, []byte("original"), 0o600); err != nil {
t.Fatal(err)
}
link, target := filepath.Join(out, "link"), external
pattern := "{{.Name}}/secret.txt"
if kind == "leaf" {
target = secret
pattern = "{{.Name}}"
}
if kind == "root" {
link = filepath.Join(out, "root")
out = link
pattern = "{{.Name}}"
}
if err := os.Symlink(target, link); err != nil {
t.Skip(err)
}
w, err := newTestWriter(t, "replacement", "schema", pattern, out)
if err != nil {
t.Fatal(err)
}
db := models.InitDatabase("links")
db.Schemas = []*models.Schema{models.InitSchema("first"), models.InitSchema("link")}
if err := w.WriteDatabase(db); err == nil {
t.Fatal("expected symlink rejection")
}
got, err := os.ReadFile(secret)
if err != nil || string(got) != "original" {
t.Fatalf("symlink target changed: %q %v", got, err)
}
if _, err := os.Stat(filepath.Join(out, "first")); !os.IsNotExist(err) {
t.Fatalf("preflight must precede writes: %v", err)
}
})
}
}
func TestMultiFileNewDirectoryAndNestedFiles(t *testing.T) {
out := filepath.Join(t.TempDir(), "new-output")
w, err := newTestWriter(t, "{{.Table.Name}}", "table", "{{.ParentSchema.Name}}/{{.Name}}.txt", out)
if err != nil {
t.Fatal(err)
}
if err := w.WriteDatabase(modeDB()); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(filepath.Join(out, "a", "t1.txt"))
if err != nil || string(got) != "t1" {
t.Fatalf("nested output: %q %v", got, err)
}
}
func TestMultiFileRootAncestorSymlinkRejected(t *testing.T) {
parent, external := t.TempDir(), t.TempDir()
link := filepath.Join(parent, "link")
if err := os.Symlink(external, link); err != nil {
t.Skip(err)
}
w, err := newTestWriter(t, "content", "schema", "{{.Name}}.txt", filepath.Join(link, "new"))
if err != nil {
t.Fatal(err)
}
if err := w.WriteDatabase(modeDB()); err == nil {
t.Fatal("root ancestor symlink must fail")
}
if _, err := os.Stat(filepath.Join(external, "new")); !os.IsNotExist(err) {
t.Fatalf("must not create external directories: %v", err)
}
}