Fix newline handling in file, softlink, and ImportLink template functions

Use state-based routing instead of sentinel strings, so whitespace-trimming
syntax (e.g. `{{- file "name" -}}`) correctly creates all named files and
symlinks.

Fixes: #2118

Signed-off-by: Jonathon Anderson <janderson@ciq.com>
Co-authored-by: Christian Goll <cgoll@suse.com>
Signed-off-by: Jonathon Anderson <janderson@ciq.com>
This commit is contained in:
Jonathon Anderson
2026-04-17 15:57:19 -06:00
parent eaac55a97a
commit 2bad789f4f
28 changed files with 767 additions and 256 deletions

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
warewulfconf "github.com/warewulf/warewulf/internal/pkg/config"
@@ -118,19 +117,6 @@ func createIgnitionJson(node *node.Node) string {
return string(tmpYaml)
}
func importSoftlink(lnk string) string {
target, err := filepath.EvalSymlinks(lnk)
if err != nil {
return "abort"
}
wwlog.Debug("importing softlink pointing to: %s", target)
return softlink(target)
}
func softlink(target string) string {
return fmt.Sprintf("{{ /* softlink \"%s\" */ }}", target)
}
// UniqueField returns a filtered version of a multi-line input string. input is
// expected to be a field-separated format with one record per line (terminated
// by `\n`). Order of lines is preserved, with the first matching line taking

View File

@@ -1,8 +1,8 @@
package overlay
import (
"bufio"
"bytes"
"errors"
"fmt"
"io/fs"
"os"
@@ -294,7 +294,13 @@ func (overlay Overlay) ParseVarFields(file string) map[string]FieldInfo {
return nil
}
funcMap, _, _ := getTemplateFuncMap(fullPath, TemplateStruct{})
renderState := &RenderedTemplate{
Files: []*RenderedFile{
{Name: ""},
},
}
renderWriter := &multiFileWriter{current: &renderState.Files[0].Buffer}
funcMap := buildTemplateFuncMap(fullPath, TemplateStruct{}, renderState, renderWriter)
tmpl, err := template.New(path.Base(fullPath)).Option("missingkey=default").Funcs(funcMap).ParseFiles(fullPath)
if err != nil {
wwlog.Error("Could not parse template file %s: %s", fullPath, err)
@@ -946,16 +952,6 @@ func BuildOverlay(nodeConf node.Node, allNodes []node.Node, context string, over
return err
}
var (
regFile *regexp.Regexp
regLink *regexp.Regexp
)
func init() {
regFile = regexp.MustCompile(`.*{{\s*/\*\s*file\s*["'](.*)["']\s*\*/\s*}}.*`)
regLink = regexp.MustCompile(`.*{{\s*/\*\s*softlink\s*["'](.*)["']\s*\*/\s*}}.*`)
}
// Build the given overlays for a node in the given directory.
func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []string, outputDir string) error {
if len(overlayNames) == 0 {
@@ -1005,7 +1001,7 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []
} else if filepath.Ext(walkPath) == ".ww" {
originalOutputPath := outputPath
outputPath := strings.TrimSuffix(outputPath, ".ww")
defaultOutputPath := strings.TrimSuffix(outputPath, ".ww")
tstruct, err := InitStruct(overlayName, nodeData, allNodes)
if err != nil {
return fmt.Errorf("failed to initial data for %s: %w", nodeData.Id(), err)
@@ -1013,79 +1009,75 @@ func BuildOverlayIndir(nodeData node.Node, allNodes []node.Node, overlayNames []
tstruct.BuildSource = walkPath
wwlog.Verbose("Evaluating overlay template file: %s", walkPath)
buffer, backupFile, writeFile, err := RenderTemplateFile(walkPath, tstruct)
rendered, err := RenderTemplate(walkPath, tstruct)
if err != nil {
return fmt.Errorf("failed to render template %s: %w", walkPath, err)
}
if !*writeFile {
if !rendered.WriteFile {
return nil
}
var fileBuffer bytes.Buffer
// search for magic file name comment
fileScanner := bufio.NewScanner(bytes.NewReader(buffer.Bytes()))
fileScanner.Split(ScanLines)
writingToNamedFile := false
isLink := false
for fileScanner.Scan() {
line := fileScanner.Text()
filenameFromTemplate := regFile.FindAllStringSubmatch(line, -1)
targetFromTemplate := regLink.FindAllStringSubmatch(line, -1)
if len(targetFromTemplate) != 0 {
target := targetFromTemplate[0][1]
wwlog.Debug("Creating soft link %s -> %s", outputPath, target)
err := os.Symlink(target, outputPath)
if err != nil {
return fmt.Errorf("could not create symlink from template: %w", err)
} else {
isLink = true
}
} else if len(filenameFromTemplate) != 0 {
wwlog.Debug("Writing file %s", filenameFromTemplate[0][1])
if writingToNamedFile && !isLink {
err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode())
if err != nil {
return fmt.Errorf("could not write file from template: %w", err)
}
err = util.CopyUIDGID(walkPath, outputPath)
if err != nil {
return fmt.Errorf("failed setting permissions on template output file: %w", err)
}
fileBuffer.Reset()
}
if path.IsAbs(filenameFromTemplate[0][1]) {
outputPath = filenameFromTemplate[0][1]
// Create parent directory for absolute paths
parentDir := path.Dir(outputPath)
sourceDirInfo, err := os.Stat(path.Dir(walkPath))
if err != nil {
return fmt.Errorf("could not stat source directory: %w", err)
}
if err := os.MkdirAll(parentDir, sourceDirInfo.Mode()); err != nil {
return fmt.Errorf("could not create parent directory for absolute path: %w", err)
}
} else {
outputPath = path.Join(path.Dir(originalOutputPath), filenameFromTemplate[0][1])
}
writingToNamedFile = true
isLink = false
} else {
if _, err = fileBuffer.WriteString(line); err != nil {
return fmt.Errorf("could not write to template buffer: %w", err)
}
}
}
if !isLink {
err = CarefulWriteBuffer(outputPath, fileBuffer, *backupFile, info.Mode())
if err != nil {
return fmt.Errorf("could not write file from template: %w", err)
}
err = util.CopyUIDGID(walkPath, outputPath)
if err != nil {
return fmt.Errorf("failed setting permissions on template output file: %w", err)
}
wwlog.Debug("Wrote template file into overlay: %s", outputPath)
}
// Write each output file. The default slot (Name == "") is written to
// defaultOutputPath when it is the only file. When named files are also
// present, the default slot is skipped unless it is a symlink: content
// before the first file() call often includes template setup (variable
// assignments, range headers) that produces incidental whitespace, and
// writing it as a separate file would be unexpected. A softlink() call
// before any file() call is always intentional and must be honored.
// Named files are always written.
for _, f := range rendered.Files {
var filePath string
if f.Name == "" {
if len(rendered.Files) > 1 && !f.IsSymlink {
continue
}
filePath = defaultOutputPath
} else if path.IsAbs(f.Name) {
// Anchor absolute paths under outputDir so they resolve within
// the image. For the host overlay (outputDir == "/") this is a
// no-op and the path writes to the real host location as intended.
filePath = filepath.Join(outputDir, f.Name)
} else {
filePath = path.Join(path.Dir(originalOutputPath), f.Name)
}
// Guard against path traversal: verify the resolved path stays
// within outputDir before writing or creating a symlink.
if rel, relErr := filepath.Rel(filepath.Clean(outputDir), filepath.Clean(filePath)); relErr != nil || strings.HasPrefix(rel, "..") {
return fmt.Errorf("file() path %q escapes output directory", f.Name)
}
// For absolute file() paths, ensure parent directories exist.
// This applies to both regular files and symlinks: os.Symlink
// fails with ENOENT if the parent directory does not exist.
if path.IsAbs(f.Name) {
parentDir := filepath.Dir(filePath)
sourceDirInfo, err := os.Stat(path.Dir(walkPath))
if err != nil {
return fmt.Errorf("could not stat source directory: %w", err)
}
if err := os.MkdirAll(parentDir, sourceDirInfo.Mode()); err != nil {
return fmt.Errorf("could not create parent directory for absolute path: %w", err)
}
}
if f.IsSymlink {
wwlog.Debug("Creating soft link %s -> %s", filePath, f.Target)
if err = os.Symlink(f.Target, filePath); err != nil {
return fmt.Errorf("could not create symlink from template: %w", err)
}
} else {
wwlog.Debug("Writing file %s", f.Name)
err = CarefulWriteBuffer(filePath, f.Buffer, rendered.BackupFile, info.Mode())
if err != nil {
return fmt.Errorf("could not write file from template: %w", err)
}
err = util.CopyUIDGID(walkPath, filePath)
if err != nil {
return fmt.Errorf("failed setting permissions on template output file: %w", err)
}
}
}
} else if info.Mode()&os.ModeSymlink == os.ModeSymlink {
wwlog.Debug("Found symlink %s", walkPath)
target, err := os.Readlink(walkPath)
@@ -1153,94 +1145,177 @@ func CarefulWriteBuffer(destFile string, buffer bytes.Buffer, backupFile bool, p
return err
}
// getTemplateFuncMap returns a template.FuncMap with all the functions
// for warewulf templates.
func getTemplateFuncMap(fileName string, data TemplateStruct) (funcMap template.FuncMap, writeFile, backupFile *bool) {
// Build our FuncMap
_writeFile := true
_backupFile := true
writeFile = &_writeFile
backupFile = &_backupFile
funcMap = template.FuncMap{
"Include": templateFileInclude,
"IncludeFrom": templateImageFileInclude,
"IncludeBlock": templateFileBlock,
"ImportLink": importSoftlink,
"basename": path.Base,
"inc": func(i int) int { return i + 1 },
"dec": func(i int) int { return i - 1 },
"file": func(str string) string { return fmt.Sprintf("{{ /* file \"%s\" */ }}", str) },
"softlink": softlink,
"readlink": filepath.EvalSymlinks,
"IgnitionJson": func() string {
return createIgnitionJson(data.ThisNode)
},
"abort": func() string {
wwlog.Debug("abort file called in %s", fileName)
*writeFile = false
return ""
},
"nobackup": func() string {
wwlog.Debug("not backup for %s", fileName)
*backupFile = false
return ""
},
// errAbort is returned by the abort() template function to halt template execution
// at the exact point of the call. RenderTemplate catches this sentinel and sets
// result.WriteFile = false rather than propagating it as a real error.
var errAbort = errors.New("abort")
// RenderedFile represents one output file or symlink produced by a .ww template.
// Template rendering uses a state-based multi-file writer instead of post-processing
// sentinel strings. When a .ww template calls file("name"), the fileFn closure appends
// a RenderedFile to the result and redirects the multiFileWriter to that file's buffer.
// All subsequent template output flows into that buffer until the next file() call.
// This approach is whitespace-trim safe: the old sentinel approach broke when {{- -}}
// caused adjacent file() calls to collapse onto a single line, causing the greedy regex
// to match only the last sentinel.
//
// A RenderedFile with an empty Name is the default slot. It holds all template
// output when no file() calls are made, or the content written before the first
// file() call when file() calls are present. The default slot is always present
// in Files[0]; callers are responsible for deciding how to handle it.
type RenderedFile struct {
Name string
IsSymlink bool
Target string
Buffer bytes.Buffer
}
// RenderedTemplate is the complete output of rendering a .ww template file.
// Files is always initialized with a default entry (Name == "") before rendering.
// When no file() calls are present, all content goes to that entry and it is the
// only element. When file() calls are present, named entries follow the default.
// The default entry is always present in Files[0] regardless of whether its
// buffer is empty; callers decide whether to act on it (see BuildOverlayIndir).
type RenderedTemplate struct {
WriteFile bool
BackupFile bool
Files []*RenderedFile
}
type multiFileWriter struct {
current *bytes.Buffer // redirected by file() and softlink() closures
}
func (w *multiFileWriter) Write(p []byte) (n int, err error) {
return w.current.Write(p)
}
// buildTemplateFuncMap constructs the template FuncMap for a .ww template.
// result and writer are the shared mutable state closed over by the stateful
// template functions (file, softlink, ImportLink, abort, nobackup). They must
// be allocated by the caller so RenderTemplate and ParseVarFields can each
// supply their own isolated state.
func buildTemplateFuncMap(fileName string, data TemplateStruct, result *RenderedTemplate, writer *multiFileWriter) template.FuncMap {
softlinkFn := func(target string) string {
if len(result.Files) > 0 {
last := result.Files[len(result.Files)-1]
last.IsSymlink = true
last.Target = target
}
return ""
}
importlinkFn := func(lnk string) (string, error) {
resolvedTarget, err := filepath.EvalSymlinks(lnk)
if err != nil {
return "", fmt.Errorf("ImportLink: failed to resolve symlink %q: %w", lnk, err)
}
wwlog.Debug("importing softlink pointing to: %s", resolvedTarget)
return softlinkFn(resolvedTarget), nil
}
// fileFn switches the active write target; returns "" so no output is emitted.
fileFn := func(name string) string {
f := &RenderedFile{Name: name}
result.Files = append(result.Files, f)
writer.current = &f.Buffer
return ""
}
incFn := func(i int) int { return i + 1 }
decFn := func(i int) int { return i - 1 }
ignitionFn := func() string {
return createIgnitionJson(data.ThisNode)
}
abortFn := func() (string, error) {
wwlog.Debug("abort file called in %s", fileName)
return "", errAbort
}
nobackupFn := func() string {
wwlog.Debug("not backup for %s", fileName)
result.BackupFile = false
return ""
}
funcMap := template.FuncMap{
"Include": templateFileInclude,
"IncludeFrom": templateImageFileInclude,
"IncludeBlock": templateFileBlock,
"ImportLink": importlinkFn,
"basename": path.Base,
"inc": incFn,
"dec": decFn,
"file": fileFn,
"softlink": softlinkFn,
"readlink": filepath.EvalSymlinks,
"IgnitionJson": ignitionFn,
"abort": abortFn,
"nobackup": nobackupFn,
"UniqueField": UniqueField,
"SystemdEscape": unit.UnitNameEscape,
"SystemdEscapePath": unit.UnitNamePathEscape,
}
// Merge sprig.FuncMap with our FuncMap
for key, value := range sprig.TxtFuncMap() {
funcMap[key] = value
}
return funcMap, writeFile, backupFile
return funcMap
}
/*
Parses the template with the given filename, variables must be in data. Returns the
parsed template as bytes.Buffer, and the bool variables for backupFile and writeFile.
If something goes wrong an error is returned.
*/
// RenderTemplate renders the .ww template at fileName with the provided data,
// returning a RenderedTemplate that describes all output files. The file() and
// softlink() template functions update state directly rather than emitting
// sentinel strings, so they work correctly regardless of whitespace trimming.
func RenderTemplate(fileName string, data TemplateStruct) (*RenderedTemplate, error) {
result := &RenderedTemplate{
WriteFile: true,
BackupFile: true,
Files: []*RenderedFile{{Name: ""}},
}
writer := &multiFileWriter{current: &result.Files[0].Buffer}
funcMap := buildTemplateFuncMap(fileName, data, result, writer)
tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName)
if err != nil {
return nil, fmt.Errorf("could not parse template %s: %w", fileName, err)
}
if err = tmpl.Execute(writer, data); err != nil {
if errors.Is(err, errAbort) {
// abort() halts execution at the call site; content up to that point
// is preserved in result.Files[0].Buffer for debugging via overlay show / API.
result.WriteFile = false
} else {
return nil, fmt.Errorf("could not execute template: %w", err)
}
}
return result, nil
}
// RenderTemplateFile is a wrapper around RenderTemplate for callers that only
// need a single rendered buffer. It returns the default slot (Files[0].Buffer):
// all content when the template makes no file() calls, or the content written
// before the first file() call when file() calls are present. Callers that need
// named output files or symlink information should use RenderTemplate directly.
func RenderTemplateFile(fileName string, data TemplateStruct) (
buffer bytes.Buffer, backupFile, writeFile *bool,
err error,
) {
funcMap, writeFile, backupFile := getTemplateFuncMap(fileName, data)
// Create the template with the merged FuncMap
tmpl, err := template.New(path.Base(fileName)).Option("missingkey=default").Funcs(funcMap).ParseGlob(fileName)
if err != nil {
err = fmt.Errorf("could not parse template %s: %w", fileName, err)
return
}
err = tmpl.Execute(&buffer, data)
if err != nil {
err = fmt.Errorf("could not execute template: %w", err)
rendered, renderErr := RenderTemplate(fileName, data)
if renderErr != nil {
err = renderErr
return
}
backupFile = &rendered.BackupFile
writeFile = &rendered.WriteFile
buffer = rendered.Files[0].Buffer
return
}
// Simple version of ScanLines, but include the line break
func ScanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\n'); i >= 0 {
// We have a full newline-terminated line.
return i + 1, data[0 : i+1], nil
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
return len(data), data, nil
}
// Request more data.
return 0, nil, nil
}
// Get all the files as a string slice for a given overlay
func (overlay Overlay) GetFiles() (files []string, err error) {
err = filepath.Walk(overlay.Rootfs(), func(path string, info fs.FileInfo, err error) error {

View File

@@ -230,8 +230,79 @@ T3
outputDir: "/image",
outputFiles: map[string]string{
"t1.txt": "\nT1\n",
"t2.txt": "T2\n",
"t3.txt": "T3\n",
"t2.txt": "\nT2\n",
"t3.txt": "\nT3\n",
},
},
"multifile whitespace trimmed": {
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- range $i, $name := list "a" "b" "c" }}
{{ file $name }}
{{- end -}}`,
},
outputDir: "/image",
outputFiles: map[string]string{
"a": "\n",
"b": "\n",
"c": "",
},
},
"abort": {
// Regression test: abort() must suppress all file output in BuildOverlayIndir.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- abort -}}`,
},
outputDir: "/image",
outputFiles: map[string]string{},
},
"multifile all empty with whitespace trimming": {
// Regression test: before the state-based rewrite, using {{- file "name" -}}
// caused adjacent file() sentinels to collapse onto one line. The greedy .*
// in the regex matched only the last sentinel, so only the final file was
// created. All three files must be created here, each with zero content.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- range $name := list "a" "b" "c" -}}
{{- file $name -}}
{{- end -}}`,
},
outputDir: "/image",
outputFiles: map[string]string{
"a": "",
"b": "",
"c": "",
},
},
"multifile default symlink written to disk": {
// A softlink() call before any file() call targets the default output
// path and must be created even when named files are also present.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `{{- softlink "/link-target" -}}{{- file "named.txt" -}}named content`,
},
outputDir: "/image",
outputFiles: map[string]string{
"named.txt": "named content",
},
outputSymlinks: map[string]string{
"file.txt": "/link-target",
},
},
"multifile pre-file content not written to disk": {
// Content written before the first file() call is preserved in the
// RenderedTemplate (RenderTemplateFile returns it), but BuildOverlayIndir
// does not write it to disk when named files are present. Only named.txt
// is created; file.txt is not, despite the non-empty default buffer.
overlays: []string{"o1"},
overlayFiles: map[string]string{
"/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww": `pre-file content
{{ file "named.txt" }}named content`,
},
outputDir: "/image",
outputFiles: map[string]string{
"named.txt": "named content",
},
},
"symlink": {
@@ -338,6 +409,24 @@ Tags:map[]
}
}
func Test_BuildOverlayIndir_PathTraversal(t *testing.T) {
tests := map[string]string{
"relative traversal": `{{- file "../../../../etc/shadow" -}}escaped`,
"absolute traversal": `{{- file "/../../etc/shadow" -}}escaped`,
}
for name, tmpl := range tests {
t.Run(name, func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("/var/lib/warewulf/overlays/o1/rootfs/file.txt.ww", tmpl)
env.MkdirAll("/image")
err := BuildOverlayIndir(node.Node{}, []node.Node{}, []string{"o1"}, env.GetPath("/image"))
assert.ErrorContains(t, err, "escapes output directory")
})
}
}
func Test_BuildOverlay(t *testing.T) {
tests := []struct {
description string
@@ -751,6 +840,223 @@ func Test_CreateOverlayFile(t *testing.T) {
}
}
func Test_RenderTemplate_ImportLink(t *testing.T) {
// Create a real symlink on the host filesystem for the success case.
// ImportLink calls filepath.EvalSymlinks on the host, not in the overlay rootfs.
tmpDir := t.TempDir()
target := filepath.Join(tmpDir, "real-target")
err := os.WriteFile(target, []byte("content"), 0644)
assert.NoError(t, err)
link := filepath.Join(tmpDir, "test-link")
err = os.Symlink(target, link)
assert.NoError(t, err)
t.Run("ImportLink success", func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
tmplContent := fmt.Sprintf(`{{- ImportLink %q -}}`, link)
tmplPath := env.GetPath("test.ww")
env.WriteFile("test.ww", tmplContent)
rendered, err := RenderTemplate(tmplPath, TemplateStruct{})
assert.NoError(t, err)
assert.True(t, rendered.Files[0].IsSymlink)
assert.Equal(t, target, rendered.Files[0].Target)
})
t.Run("ImportLink failure", func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
tmplPath := env.GetPath("test.ww")
env.WriteFile("test.ww", `{{- ImportLink "/nonexistent/path/that/does/not/exist" -}}`)
_, err := RenderTemplate(tmplPath, TemplateStruct{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "ImportLink")
})
}
func TestRenderTemplate(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
// Setup for Include and IncludeBlock
includeDir := env.GetPath(path.Join(testenv.Sysconfdir, "warewulf"))
env.MkdirAll(path.Join(testenv.Sysconfdir, "warewulf"))
includeFile := path.Join(includeDir, "test-include")
err := os.WriteFile(includeFile, []byte("included content\n"), 0644)
assert.NoError(t, err)
blockFile := path.Join(includeDir, "test-block")
err = os.WriteFile(blockFile, []byte("line1\nline2\nABORT\nline3\n"), 0644)
assert.NoError(t, err)
// Setup for IncludeFrom
imageName := "test-image"
imageRootfs := env.GetPath(path.Join(testenv.WWChrootdir, imageName, "rootfs"))
env.MkdirAll(path.Join(testenv.WWChrootdir, imageName, "rootfs"))
err = os.WriteFile(path.Join(imageRootfs, "file-in-image"), []byte("image content\n"), 0644)
assert.NoError(t, err)
tests := []struct {
name string
template string
data TemplateStruct
validate func(*testing.T, *RenderedTemplate, error)
}{
{
name: "Basic functions",
template: `{{inc 1}} {{dec 5}} {{basename "/path/to/file"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "2 4 file", rt.Files[0].Buffer.String())
},
},
{
name: "File redirection",
template: `default content{{file "other"}}other content`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
// Pre-file() content is in the default slot; the named slot follows it.
assert.Len(t, rt.Files, 2)
assert.Equal(t, "", rt.Files[0].Name)
assert.Equal(t, "default content", rt.Files[0].Buffer.String())
assert.Equal(t, "other", rt.Files[1].Name)
assert.Equal(t, "other content", rt.Files[1].Buffer.String())
},
},
{
name: "Softlink",
template: `{{softlink "/target"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.True(t, rt.Files[0].IsSymlink)
assert.Equal(t, "/target", rt.Files[0].Target)
},
},
{
name: "No backup",
template: `{{nobackup}}content`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.False(t, rt.BackupFile)
},
},
{
name: "Abort",
template: `some content{{abort}}more content`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.False(t, rt.WriteFile)
assert.Contains(t, rt.Files[0].Buffer.String(), "some content")
assert.NotContains(t, rt.Files[0].Buffer.String(), "more content")
},
},
{
name: "Systemd Escape",
template: `{{SystemdEscape "foo-bar/baz"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "foo\\x2dbar-baz", rt.Files[0].Buffer.String())
},
},
{
name: "Include",
template: `{{Include "test-include"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "included content", rt.Files[0].Buffer.String())
},
},
{
name: "IncludeBlock",
template: `{{IncludeBlock "test-block" "ABORT"}}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "line1\nline2\nABORT", rt.Files[0].Buffer.String())
},
},
{
name: "IncludeFrom",
template: fmt.Sprintf(`{{IncludeFrom %q "file-in-image"}}`, imageName),
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "image content", rt.Files[0].Buffer.String())
},
},
{
name: "Sprig functions",
template: `{{ "hello" | upper }}`,
validate: func(t *testing.T, rt *RenderedTemplate, err error) {
assert.NoError(t, err)
assert.Equal(t, "HELLO", rt.Files[0].Buffer.String())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpFile := path.Join(env.BaseDir, tt.name+".ww")
err := os.WriteFile(tmpFile, []byte(tt.template), 0644)
assert.NoError(t, err)
rt, err := RenderTemplate(tmpFile, tt.data)
tt.validate(t, rt, err)
})
}
}
func TestRenderTemplateFile(t *testing.T) {
t.Run("no file() calls returns all content", func(t *testing.T) {
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `hello world`)
buf, backupFile, writeFile, err := RenderTemplateFile(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.True(t, *backupFile)
assert.True(t, *writeFile)
assert.Equal(t, "hello world", buf.String())
})
t.Run("pre-file() content is returned when file() calls are present", func(t *testing.T) {
// When a template writes content before the first file() call,
// RenderTemplateFile returns that pre-file() content from the default slot.
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `pre-file content{{ file "other" }}other content`)
buf, _, _, err := RenderTemplateFile(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.Equal(t, "pre-file content", buf.String())
})
t.Run("empty default slot is always preserved when file() calls are present", func(t *testing.T) {
// When there is no content before the first file() call, the empty
// default slot is retained. RenderTemplate always returns it as Files[0].
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `{{- file "other" -}}other content`)
rendered, err := RenderTemplate(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.Len(t, rendered.Files, 2)
assert.Equal(t, "", rendered.Files[0].Name)
assert.Equal(t, "", rendered.Files[0].Buffer.String())
assert.Equal(t, "other", rendered.Files[1].Name)
assert.Equal(t, "other content", rendered.Files[1].Buffer.String())
})
t.Run("RenderTemplateFile returns empty buffer not named-file content", func(t *testing.T) {
// When the template has no pre-file() content, RenderTemplateFile must
// return an empty buffer (the default slot), not the content of the named
// file. Before the fix, the stripped default slot caused Files[0] to be
// the named file, so RenderTemplateFile silently returned named content.
env := testenv.New(t)
defer env.RemoveAll()
env.WriteFile("test.ww", `{{- file "other" -}}other content`)
buf, _, _, err := RenderTemplateFile(env.GetPath("test.ww"), TemplateStruct{})
assert.NoError(t, err)
assert.Equal(t, "", buf.String())
})
}
func dirIsEmpty(t *testing.T, name string) bool {
f, err := os.Open(name)
if err != nil {