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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user