feat(coderd/templatebuilder): validation extraction, static file bundling, Windows OS (#26633)

Extract validation helpers to a dedicated file, add static file bundling
for base templates, and add Windows OS support.

**Commit 1: Extract validation to `validate.go`**
Move `validateVariableValue`, `validateStringValue`,
`validateNumberValue`, `validateBoolValue`, `toHCLLiteral`, `hclQuote`,
and `isSimpleJSONValue` from `compose.go` into `validate.go`.
Corresponding tests move to `validate_internal_test.go`. This keeps
`compose.go` focused on the compose pipeline.

**Commit 2: Static file bundling**
Add `StaticFiles` field to `ComposeResult` and a `collectStaticFiles`
helper that walks the base template FS to collect non-template files
(e.g. cloud-init `.tftpl` inputs). `BundleTar` now writes these files
into the output archive in sorted order for deterministic output. This
fixes `aws-linux`, whose cloud-init files were embedded but never
included in the tar.

**Commit 3: Windows OS support**
Add `BaseOSWindows` constant and register `"windows"` in `validBaseOS`
so that base templates with `os="windows"` can be loaded and used for
module compatibility filtering.

> [!NOTE]
> This PR was authored by Coder Agents on behalf of @jeremyruppel.
This commit is contained in:
Jeremy Ruppel
2026-06-30 18:08:02 -04:00
committed by GitHub
parent 2ea0d5f8ef
commit 6f0b81cfbc
7 changed files with 512 additions and 325 deletions
+118 -47
View File
@@ -17,12 +17,14 @@ import (
type BaseOS string
const (
BaseOSLinux BaseOS = "linux"
BaseOSLinux BaseOS = "linux"
BaseOSWindows BaseOS = "windows"
)
// validBaseOS maps base.json os strings to their typed equivalents.
var validBaseOS = map[string]BaseOS{
"linux": BaseOSLinux,
"linux": BaseOSLinux,
"windows": BaseOSWindows,
}
//go:embed bases
@@ -55,8 +57,9 @@ type parsedBase struct {
Manifest BaseManifest
Templates map[string]*template.Template
FS fs.FS
Readme string // full README.md content (including frontmatter)
Prerequisites string // content between prerequisite comment markers
Readme string // full README.md content (including frontmatter)
Prerequisites string // content between prerequisite comment markers
ExtraFiles map[string][]byte // non-template, non-manifest files (e.g. .tftpl)
}
var loadBases = sync.OnceValues(func() (map[string]*parsedBase, error) {
@@ -83,57 +86,85 @@ func parseBasesFromFS(fsys fs.FS) (map[string]*parsedBase, error) {
continue
}
manifestPath := path.Join(dir.Name(), "base.json")
data, err := fs.ReadFile(sub, manifestPath)
base, err := parseBaseDir(sub, dir.Name())
if err != nil {
return nil, xerrors.Errorf("read %s: %w", manifestPath, err)
return nil, err
}
var manifest BaseManifest
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&manifest); err != nil {
return nil, xerrors.Errorf("decode %s: %w", manifestPath, err)
}
if manifest.ID == "" {
return nil, xerrors.Errorf("base in %s has empty id", dir.Name())
}
if _, ok := validBaseOS[manifest.OS]; !ok && manifest.OS != "" {
return nil, xerrors.Errorf("base %q has unknown os %q", manifest.ID, manifest.OS)
}
if bases[manifest.ID] != nil {
return nil, xerrors.Errorf("duplicate base id %q", manifest.ID)
}
baseFS, err := fs.Sub(sub, dir.Name())
if err != nil {
return nil, xerrors.Errorf("sub fs for %s: %w", dir.Name(), err)
}
templates, err := parseTemplatesFromFS(baseFS)
if err != nil {
return nil, xerrors.Errorf("parse templates for base %q: %w", manifest.ID, err)
}
readmeData, err := fs.ReadFile(baseFS, "README.md")
if err != nil {
return nil, xerrors.Errorf("read README.md for base %q: %w", manifest.ID, err)
}
readme := string(readmeData)
bases[manifest.ID] = &parsedBase{
Manifest: manifest,
Templates: templates,
FS: baseFS,
Readme: readme,
Prerequisites: ExtractPrerequisites(readme),
if bases[base.Manifest.ID] != nil {
return nil, xerrors.Errorf("duplicate base id %q", base.Manifest.ID)
}
bases[base.Manifest.ID] = base
}
return bases, nil
}
// parseBaseDir loads a single base template directory: reads the
// manifest, pre-parses Go templates, reads the README, and collects
// extra files.
func parseBaseDir(parent fs.FS, dirName string) (*parsedBase, error) {
manifest, err := parseManifest(parent, dirName)
if err != nil {
return nil, err
}
baseFS, err := fs.Sub(parent, dirName)
if err != nil {
return nil, xerrors.Errorf("sub fs for %s: %w", dirName, err)
}
templates, err := parseTemplatesFromFS(baseFS)
if err != nil {
return nil, xerrors.Errorf("parse templates for base %q: %w", manifest.ID, err)
}
readmeData, err := fs.ReadFile(baseFS, "README.md")
if err != nil {
return nil, xerrors.Errorf("read README.md for base %q: %w", manifest.ID, err)
}
readme := string(readmeData)
extraFiles, err := collectExtraFilesFromFS(baseFS)
if err != nil {
return nil, xerrors.Errorf("collect extra files for base %q: %w", manifest.ID, err)
}
return &parsedBase{
Manifest: manifest,
Templates: templates,
FS: baseFS,
Readme: readme,
Prerequisites: ExtractPrerequisites(readme),
ExtraFiles: extraFiles,
}, nil
}
// parseManifest reads and validates a base.json file from the given
// directory within parent.
func parseManifest(parent fs.FS, dirName string) (BaseManifest, error) {
manifestPath := path.Join(dirName, "base.json")
data, err := fs.ReadFile(parent, manifestPath)
if err != nil {
return BaseManifest{}, xerrors.Errorf("read %s: %w", manifestPath, err)
}
var manifest BaseManifest
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&manifest); err != nil {
return BaseManifest{}, xerrors.Errorf("decode %s: %w", manifestPath, err)
}
if manifest.ID == "" {
return BaseManifest{}, xerrors.Errorf("base in %s has empty id", dirName)
}
if _, ok := validBaseOS[manifest.OS]; !ok && manifest.OS != "" {
return BaseManifest{}, xerrors.Errorf("base %q has unknown os %q", manifest.ID, manifest.OS)
}
return manifest, nil
}
// parseTemplatesFromFS walks the filesystem and pre-parses all .tf.tmpl files
// into Go templates. Returned keys are paths relative to the FS root.
func parseTemplatesFromFS(fsys fs.FS) (map[string]*template.Template, error) {
@@ -264,3 +295,43 @@ func BasePrerequisites(exampleID string) string {
}
return bases[exampleID].Prerequisites
}
// BaseExtraFiles returns the non-template, non-manifest files embedded
// in the base template directory (e.g. cloud-init .tftpl files). Returns
// nil if the base is unknown or has no extra files.
func BaseExtraFiles(exampleID string) map[string][]byte {
bases, err := loadBases()
if err != nil || bases[exampleID] == nil {
return nil
}
return bases[exampleID].ExtraFiles
}
// collectExtraFilesFromFS walks a base template filesystem and returns
// all files that are not Go templates (.tf.tmpl), the manifest
// (base.json), or the README. These are raw files that must be included
// in the output archive (e.g. Terraform templatefile() inputs).
func collectExtraFilesFromFS(baseFS fs.FS) (map[string][]byte, error) {
files := make(map[string][]byte)
err := fs.WalkDir(baseFS, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if p == "base.json" || p == "README.md" || strings.HasSuffix(p, ".tf.tmpl") {
return nil
}
data, err := fs.ReadFile(baseFS, p)
if err != nil {
return xerrors.Errorf("read %s: %w", p, err)
}
files[p] = data
return nil
})
if err != nil {
return nil, err
}
return files, nil
}
@@ -250,4 +250,25 @@ func TestParseBasesFromFS(t *testing.T) {
require.NoError(t, err)
require.Equal(t, "", bases["nospec"].Manifest.OS)
})
t.Run("AcceptsWindowsOS", func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{
"bases/winbox/base.json": &fstest.MapFile{
Data: []byte(`{"id": "winbox", "os": "windows"}`),
},
"bases/winbox/main.tf.tmpl": &fstest.MapFile{
Data: []byte(`resource "coder_agent" "main" {}`),
},
"bases/winbox/README.md": &fstest.MapFile{
Data: []byte("# Windows\n"),
},
}
bases, err := parseBasesFromFS(fsys)
require.NoError(t, err)
require.Equal(t, "windows", bases["winbox"].Manifest.OS)
require.Equal(t, BaseOSWindows, validBaseOS[bases["winbox"].Manifest.OS])
})
}
+26 -117
View File
@@ -3,22 +3,14 @@ package templatebuilder
import (
"archive/tar"
"bytes"
"encoding/json"
"maps"
"regexp"
"strings"
"slices"
"time"
"github.com/hashicorp/hcl/v2/hclwrite"
"golang.org/x/xerrors"
)
// maxStringValueLen is the maximum byte length for a string variable value.
const maxStringValueLen = 4096
// numberPattern matches valid HCL number literals (integers and decimals).
var numberPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
// ComposeRequest describes which base template and modules to render.
type ComposeRequest struct {
BaseTemplateID string
@@ -50,6 +42,10 @@ type ComposeResult struct {
// Readme is the full README.md content from the base template.
// Empty when the base has no README.
Readme []byte
// ExtraFiles holds non-template files from the base directory
// (e.g. cloud-init .tftpl files). Keys are paths relative to the
// base directory.
ExtraFiles map[string][]byte
}
// Compose renders a base template and selected modules into Terraform
@@ -61,10 +57,13 @@ func Compose(req ComposeRequest) (*ComposeResult, error) {
return nil, err
}
extraFiles := BaseExtraFiles(req.BaseTemplateID)
if len(req.Modules) == 0 {
return &ComposeResult{
MainTF: formatHCL(mainTF),
Readme: []byte(BaseReadme(req.BaseTemplateID)),
MainTF: formatHCL(mainTF),
Readme: []byte(BaseReadme(req.BaseTemplateID)),
ExtraFiles: extraFiles,
}, nil
}
@@ -89,9 +88,10 @@ func Compose(req ComposeRequest) (*ComposeResult, error) {
}
result := &ComposeResult{
MainTF: formatHCL(mainTF),
ModulesTF: formatHCL(modulesTF),
Readme: []byte(BaseReadme(req.BaseTemplateID)),
MainTF: formatHCL(mainTF),
ModulesTF: formatHCL(modulesTF),
Readme: []byte(BaseReadme(req.BaseTemplateID)),
ExtraFiles: extraFiles,
}
return result, nil
}
@@ -333,109 +333,6 @@ func mergeModuleVariables(manifest ModuleManifest, callerVars map[string]string)
return merged, nil
}
// validateVariableValue checks that the caller-supplied value is valid for
// the variable's declared type. String values are plain text (not
// pre-quoted); quoting for HCL happens later in toHCLLiteral.
// The literal "null" is accepted for any type.
func validateVariableValue(v ModuleVariable, value string) error {
if value == "null" {
return nil
}
switch v.Type {
case "string":
return validateStringValue(value)
case "number":
return validateNumberValue(value)
case "bool":
return validateBoolValue(value)
default:
return xerrors.Errorf("unsupported variable type %q", v.Type)
}
}
// toHCLLiteral converts a validated caller value into an HCL literal.
// The literal "null" is passed through for any type. Strings are wrapped
// in quotes with interior characters escaped; bools and numbers are
// already valid HCL literals.
func toHCLLiteral(v ModuleVariable, value string) string {
if value == "null" {
return value
}
if v.Type == "string" {
return hclQuote(value)
}
return value
}
// validateStringValue checks that a raw (unquoted) string value is safe
// to embed in an HCL quoted string. It rejects HCL interpolation/directive
// markers and values that exceed the maximum length.
func validateStringValue(value string) error {
if len(value) > maxStringValueLen {
return xerrors.Errorf("value exceeds maximum length of %d bytes", maxStringValueLen)
}
if strings.Contains(value, "${") || strings.Contains(value, "%{") {
return xerrors.New("must not contain HCL interpolation or directive sequences")
}
return nil
}
// hclQuote wraps a raw string in HCL double-quotes, escaping backslashes,
// double-quotes, and newlines so the result is a valid HCL string literal.
func hclQuote(s string) string {
var b strings.Builder
b.Grow(len(s) + 2)
_, _ = b.WriteRune('"')
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
_, _ = b.WriteString("\\\\")
case '"':
_, _ = b.WriteString("\\\"")
case '\n':
_, _ = b.WriteString("\\n")
case '\r':
_, _ = b.WriteString("\\r")
default:
_ = b.WriteByte(s[i])
}
}
_, _ = b.WriteRune('"')
return b.String()
}
// validateNumberValue checks that value is a valid HCL number literal.
func validateNumberValue(value string) error {
if !numberPattern.MatchString(value) {
return xerrors.Errorf("invalid number value %q, must be a numeric literal (e.g. 42, 3.14)", value)
}
return nil
}
// validateBoolValue checks that value is exactly "true" or "false".
func validateBoolValue(value string) error {
if value != "true" && value != "false" {
return xerrors.Errorf("invalid bool value %q, must be true or false", value)
}
return nil
}
// isSimpleJSONValue returns true if raw is a valid JSON string, number,
// bool, or null. Arrays and objects are rejected; the template builder
// only supports simple variable types.
func isSimpleJSONValue(raw json.RawMessage) bool {
var v interface{}
if err := json.Unmarshal(raw, &v); err != nil {
return false
}
switch v.(type) {
case string, float64, bool, nil:
return true
default:
return false
}
}
// BundleTar packages the compose result into a tar archive suitable for
// the Coder file store.
func BundleTar(result *ComposeResult) ([]byte, error) {
@@ -462,6 +359,18 @@ func BundleTar(result *ComposeResult) ([]byte, error) {
}
}
// Write extra files in sorted order for reproducible archives.
names := make([]string, 0, len(result.ExtraFiles))
for name := range result.ExtraFiles {
names = append(names, name)
}
slices.Sort(names)
for _, name := range names {
if err := writeTarFile(tw, name, result.ExtraFiles[name]); err != nil {
return nil, xerrors.Errorf("write %s to tar: %w", name, err)
}
}
if err := tw.Close(); err != nil {
return nil, xerrors.Errorf("close tar writer: %w", err)
}
@@ -2,45 +2,11 @@ package templatebuilder
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestIsSimpleJSONValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw json.RawMessage
want bool
}{
{"String", json.RawMessage(`"hello"`), true},
{"EmptyString", json.RawMessage(`""`), true},
{"True", json.RawMessage(`true`), true},
{"False", json.RawMessage(`false`), true},
{"Null", json.RawMessage(`null`), true},
{"PositiveInt", json.RawMessage(`42`), true},
{"NegativeInt", json.RawMessage(`-1`), true},
{"Float", json.RawMessage(`3.14`), true},
{"Array", json.RawMessage(`[1,2]`), false},
{"Object", json.RawMessage(`{"a":1}`), false},
{"Empty", json.RawMessage(``), false},
{"Nil", nil, false},
{"MalformedString", json.RawMessage(`"unclosed`), false},
{"MalformedBool", json.RawMessage(`truesomething`), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := isSimpleJSONValue(tc.raw)
require.Equal(t, tc.want, got)
})
}
}
func TestMergeModuleVariables(t *testing.T) {
t.Parallel()
@@ -189,130 +155,3 @@ func TestMergeModuleVariables(t *testing.T) {
require.Equal(t, "13337", merged["port"])
})
}
func TestValidateStringValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr string
}{
{"ValidEmpty", "", ""},
{"ValidSimple", "hello", ""},
{"ValidPath", "/home/coder", ""},
{"ValidURL", "https://github.com/coder/coder", ""},
{"ValidWithQuotes", `say "hi"`, ""},
{"ValidWithNewlines", "line\nbreak", ""},
{"ValidWithBackslash", `path\to\file`, ""},
{"RejectedHCLInterpolation", "${var.foo}", "interpolation"},
{"RejectedHCLDirective", "%{if true}yes%{endif}", "interpolation"},
{"RejectedOverlong", strings.Repeat("a", maxStringValueLen+1), "maximum length"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateStringValue(tc.value)
if tc.wantErr == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErr)
}
})
}
}
func TestValidateNumberValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr bool
}{
{"Zero", "0", false},
{"Positive", "42", false},
{"Negative", "-1", false},
{"Decimal", "3.14", false},
{"NegativeDecimal", "-0.5", false},
{"Scientific", "1e10", true},
{"Hex", "0x1F", true},
{"Underscore", "1_000", true},
{"Expression", "1 + 1", true},
{"Empty", "", true},
{"Letters", "abc", true},
{"TrailingDot", "1.", true},
{"LeadingDot", ".5", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateNumberValue(tc.value)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateBoolValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr bool
}{
{"True", "true", false},
{"False", "false", false},
{"UpperTrue", "True", true},
{"UpperFALSE", "FALSE", true},
{"QuotedTrue", `"true"`, true},
{"One", "1", true},
{"Yes", "yes", true},
{"Empty", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateBoolValue(tc.value)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateVariableValue(t *testing.T) {
t.Parallel()
t.Run("NullAcceptedForAllTypes", func(t *testing.T) {
t.Parallel()
for _, typ := range []string{"string", "number", "bool"} {
t.Run(typ, func(t *testing.T) {
t.Parallel()
v := ModuleVariable{Name: "test", Type: typ}
require.NoError(t, validateVariableValue(v, "null"))
})
}
})
t.Run("UnsupportedTypeRejected", func(t *testing.T) {
t.Parallel()
v := ModuleVariable{Name: "test", Type: "list"}
err := validateVariableValue(v, "val")
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported variable type")
})
}
+60
View File
@@ -66,6 +66,18 @@ func TestCompose(t *testing.T) {
require.Contains(t, string(result.ModulesTF), `coder_agent.dev.id`)
})
t.Run("AWSLinuxExtraFiles", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "aws-linux",
RegistryURL: "https://registry.coder.com",
})
require.NoError(t, err)
require.NotNil(t, result.ExtraFiles, "aws-linux should have extra files")
require.Contains(t, result.ExtraFiles, "cloud-init/cloud-config.yaml.tftpl")
require.Contains(t, result.ExtraFiles, "cloud-init/userdata.sh.tftpl")
})
t.Run("SensitiveVariable", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
@@ -147,6 +159,16 @@ func TestCompose(t *testing.T) {
require.Contains(t, err.Error(), "conflicts with")
})
t.Run("DockerNoExtraFiles", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "docker",
RegistryURL: "https://registry.coder.com",
})
require.NoError(t, err)
require.Empty(t, result.ExtraFiles, "docker should have no extra files")
})
t.Run("UnknownBase", func(t *testing.T) {
t.Parallel()
_, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
@@ -320,6 +342,44 @@ func TestBundleTar(t *testing.T) {
require.Equal(t, string(result.Readme), files["README.md"])
})
t.Run("ExtraFilesInTar", func(t *testing.T) {
t.Parallel()
result := &templatebuilder.ComposeResult{
MainTF: []byte("resource {}"),
ExtraFiles: map[string][]byte{
"cloud-init/config.yaml.tftpl": []byte("cloud config"),
"cloud-init/userdata.sh.tftpl": []byte("userdata"),
},
}
data, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
files := extractTar(t, data)
require.Contains(t, files, "main.tf")
require.Contains(t, files, "cloud-init/config.yaml.tftpl")
require.Contains(t, files, "cloud-init/userdata.sh.tftpl")
require.Equal(t, "cloud config", files["cloud-init/config.yaml.tftpl"])
require.Equal(t, "userdata", files["cloud-init/userdata.sh.tftpl"])
})
t.Run("AWSLinuxRoundTrip", func(t *testing.T) {
t.Parallel()
result, err := templatebuilder.Compose(templatebuilder.ComposeRequest{
BaseTemplateID: "aws-linux",
RegistryURL: "https://registry.coder.com",
})
require.NoError(t, err)
data, err := templatebuilder.BundleTar(result)
require.NoError(t, err)
files := extractTar(t, data)
require.Contains(t, files, "main.tf")
require.Contains(t, files, "README.md")
require.Contains(t, files, "cloud-init/cloud-config.yaml.tftpl")
require.Contains(t, files, "cloud-init/userdata.sh.tftpl")
})
t.Run("ReproducibleArchive", func(t *testing.T) {
t.Parallel()
result := &templatebuilder.ComposeResult{
+118
View File
@@ -0,0 +1,118 @@
package templatebuilder
import (
"encoding/json"
"regexp"
"strings"
"golang.org/x/xerrors"
)
// maxStringValueLen is the maximum byte length for a string variable value.
const maxStringValueLen = 4096
// numberPattern matches valid HCL number literals (integers and decimals).
var numberPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
// validateVariableValue checks that the caller-supplied value is valid for
// the variable's declared type. String values are plain text (not
// pre-quoted); quoting for HCL happens later in toHCLLiteral.
// The literal "null" is accepted for any type.
func validateVariableValue(v ModuleVariable, value string) error {
if value == "null" {
return nil
}
switch v.Type {
case "string":
return validateStringValue(value)
case "number":
return validateNumberValue(value)
case "bool":
return validateBoolValue(value)
default:
return xerrors.Errorf("unsupported variable type %q", v.Type)
}
}
// toHCLLiteral converts a validated caller value into an HCL literal.
// The literal "null" is passed through for any type. Strings are wrapped
// in quotes with interior characters escaped; bools and numbers are
// already valid HCL literals.
func toHCLLiteral(v ModuleVariable, value string) string {
if value == "null" {
return value
}
if v.Type == "string" {
return hclQuote(value)
}
return value
}
// validateStringValue checks that a raw (unquoted) string value is safe
// to embed in an HCL quoted string. It rejects HCL interpolation/directive
// markers and values that exceed the maximum length.
func validateStringValue(value string) error {
if len(value) > maxStringValueLen {
return xerrors.Errorf("value exceeds maximum length of %d bytes", maxStringValueLen)
}
if strings.Contains(value, "${") || strings.Contains(value, "%{") {
return xerrors.New("must not contain HCL interpolation or directive sequences")
}
return nil
}
// hclQuote wraps a raw string in HCL double-quotes, escaping backslashes,
// double-quotes, and newlines so the result is a valid HCL string literal.
func hclQuote(s string) string {
var b strings.Builder
b.Grow(len(s) + 2)
_, _ = b.WriteRune('"')
for i := 0; i < len(s); i++ {
switch s[i] {
case '\\':
_, _ = b.WriteString("\\\\")
case '"':
_, _ = b.WriteString("\\\"")
case '\n':
_, _ = b.WriteString("\\n")
case '\r':
_, _ = b.WriteString("\\r")
default:
_ = b.WriteByte(s[i])
}
}
_, _ = b.WriteRune('"')
return b.String()
}
// validateNumberValue checks that value is a valid HCL number literal.
func validateNumberValue(value string) error {
if !numberPattern.MatchString(value) {
return xerrors.Errorf("invalid number value %q, must be a numeric literal (e.g. 42, 3.14)", value)
}
return nil
}
// validateBoolValue checks that value is exactly "true" or "false".
func validateBoolValue(value string) error {
if value != "true" && value != "false" {
return xerrors.Errorf("invalid bool value %q, must be true or false", value)
}
return nil
}
// isSimpleJSONValue returns true if raw is a valid JSON string, number,
// bool, or null. Arrays and objects are rejected; the template builder
// only supports simple variable types.
func isSimpleJSONValue(raw json.RawMessage) bool {
var v interface{}
if err := json.Unmarshal(raw, &v); err != nil {
return false
}
switch v.(type) {
case string, float64, bool, nil:
return true
default:
return false
}
}
@@ -0,0 +1,169 @@
package templatebuilder
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestIsSimpleJSONValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
raw json.RawMessage
want bool
}{
{"String", json.RawMessage(`"hello"`), true},
{"EmptyString", json.RawMessage(`""`), true},
{"True", json.RawMessage(`true`), true},
{"False", json.RawMessage(`false`), true},
{"Null", json.RawMessage(`null`), true},
{"PositiveInt", json.RawMessage(`42`), true},
{"NegativeInt", json.RawMessage(`-1`), true},
{"Float", json.RawMessage(`3.14`), true},
{"Array", json.RawMessage(`[1,2]`), false},
{"Object", json.RawMessage(`{"a":1}`), false},
{"Empty", json.RawMessage(``), false},
{"Nil", nil, false},
{"MalformedString", json.RawMessage(`"unclosed`), false},
{"MalformedBool", json.RawMessage(`truesomething`), false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := isSimpleJSONValue(tc.raw)
require.Equal(t, tc.want, got)
})
}
}
func TestValidateStringValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr string
}{
{"ValidEmpty", "", ""},
{"ValidSimple", "hello", ""},
{"ValidPath", "/home/coder", ""},
{"ValidURL", "https://github.com/coder/coder", ""},
{"ValidWithQuotes", `say "hi"`, ""},
{"ValidWithNewlines", "line\nbreak", ""},
{"ValidWithBackslash", `path\to\file`, ""},
{"RejectedHCLInterpolation", "${var.foo}", "interpolation"},
{"RejectedHCLDirective", "%{if true}yes%{endif}", "interpolation"},
{"RejectedOverlong", strings.Repeat("a", maxStringValueLen+1), "maximum length"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateStringValue(tc.value)
if tc.wantErr == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErr)
}
})
}
}
func TestValidateNumberValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr bool
}{
{"Zero", "0", false},
{"Positive", "42", false},
{"Negative", "-1", false},
{"Decimal", "3.14", false},
{"NegativeDecimal", "-0.5", false},
{"Scientific", "1e10", true},
{"Hex", "0x1F", true},
{"Underscore", "1_000", true},
{"Expression", "1 + 1", true},
{"Empty", "", true},
{"Letters", "abc", true},
{"TrailingDot", "1.", true},
{"LeadingDot", ".5", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateNumberValue(tc.value)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateBoolValue(t *testing.T) {
t.Parallel()
tests := []struct {
name string
value string
wantErr bool
}{
{"True", "true", false},
{"False", "false", false},
{"UpperTrue", "True", true},
{"UpperFALSE", "FALSE", true},
{"QuotedTrue", `"true"`, true},
{"One", "1", true},
{"Yes", "yes", true},
{"Empty", "", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := validateBoolValue(tc.value)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestValidateVariableValue(t *testing.T) {
t.Parallel()
t.Run("NullAcceptedForAllTypes", func(t *testing.T) {
t.Parallel()
for _, typ := range []string{"string", "number", "bool"} {
t.Run(typ, func(t *testing.T) {
t.Parallel()
v := ModuleVariable{Name: "test", Type: typ}
require.NoError(t, validateVariableValue(v, "null"))
})
}
})
t.Run("UnsupportedTypeRejected", func(t *testing.T) {
t.Parallel()
v := ModuleVariable{Name: "test", Type: "list"}
err := validateVariableValue(v, "val")
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported variable type")
})
}