fix: contain generated files and assets and harden SQL rendering
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user