diff --git a/coderd/templatebuilder/bases.go b/coderd/templatebuilder/bases.go index 2d6a2406eb..774167a619 100644 --- a/coderd/templatebuilder/bases.go +++ b/coderd/templatebuilder/bases.go @@ -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 +} diff --git a/coderd/templatebuilder/bases_internal_test.go b/coderd/templatebuilder/bases_internal_test.go index 19187dbf2e..7dac314d86 100644 --- a/coderd/templatebuilder/bases_internal_test.go +++ b/coderd/templatebuilder/bases_internal_test.go @@ -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]) + }) } diff --git a/coderd/templatebuilder/compose.go b/coderd/templatebuilder/compose.go index dc7bb0853d..dfe90d103c 100644 --- a/coderd/templatebuilder/compose.go +++ b/coderd/templatebuilder/compose.go @@ -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) } diff --git a/coderd/templatebuilder/compose_internal_test.go b/coderd/templatebuilder/compose_internal_test.go index 3849c32f11..14a3f3b041 100644 --- a/coderd/templatebuilder/compose_internal_test.go +++ b/coderd/templatebuilder/compose_internal_test.go @@ -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") - }) -} diff --git a/coderd/templatebuilder/compose_test.go b/coderd/templatebuilder/compose_test.go index 0bf1eb851a..1373146783 100644 --- a/coderd/templatebuilder/compose_test.go +++ b/coderd/templatebuilder/compose_test.go @@ -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{ diff --git a/coderd/templatebuilder/validate.go b/coderd/templatebuilder/validate.go new file mode 100644 index 0000000000..4123587b53 --- /dev/null +++ b/coderd/templatebuilder/validate.go @@ -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 + } +} diff --git a/coderd/templatebuilder/validate_internal_test.go b/coderd/templatebuilder/validate_internal_test.go new file mode 100644 index 0000000000..6c85279eab --- /dev/null +++ b/coderd/templatebuilder/validate_internal_test.go @@ -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") + }) +}