From c1991b3d51abc8e36eb2d5ae722b55909d20f81e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hozza?= Date: Tue, 31 Jan 2023 21:53:24 +0100 Subject: [PATCH] blueprint: add representation of Directories and Files customization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the Blueprint customizations with the representation for custom Directories and Files specified by the user. Implement custom Unmarshalers for TOML and JSON. These ensure that all user-provided values are validated before use and also handle the fact that user and group ownership for directories and files can be specifies as a string or as an integer. Implement helper functions for converting the Blueprint-specific types for these customizations to their internal representation from `fsnode` package. Signed-off-by: Tomáš Hozza --- internal/blueprint/customizations.go | 16 + internal/blueprint/fsnode_customizations.go | 285 +++++++ .../blueprint/fsnode_customizations_test.go | 797 ++++++++++++++++++ 3 files changed, 1098 insertions(+) create mode 100644 internal/blueprint/fsnode_customizations.go create mode 100644 internal/blueprint/fsnode_customizations_test.go diff --git a/internal/blueprint/customizations.go b/internal/blueprint/customizations.go index 89221f401..5656894ed 100644 --- a/internal/blueprint/customizations.go +++ b/internal/blueprint/customizations.go @@ -21,6 +21,8 @@ type Customizations struct { FDO *FDOCustomization `json:"fdo,omitempty" toml:"fdo,omitempty"` OpenSCAP *OpenSCAPCustomization `json:"openscap,omitempty" toml:"openscap,omitempty"` Ignition *IgnitionCustomization `json:"ignition,omitempty" toml:"ignition,omitempty"` + Directories []DirectoryCustomization `json:"directories,omitempty" toml:"directories,omitempty"` + Files []FileCustomization `json:"files,omitempty" toml:"files,omitempty"` } type IgnitionCustomization struct { @@ -341,3 +343,17 @@ func (c *Customizations) GetIgnition() *IgnitionCustomization { } return c.Ignition } + +func (c *Customizations) GetDirectories() []DirectoryCustomization { + if c == nil { + return nil + } + return c.Directories +} + +func (c *Customizations) GetFiles() []FileCustomization { + if c == nil { + return nil + } + return c.Files +} diff --git a/internal/blueprint/fsnode_customizations.go b/internal/blueprint/fsnode_customizations.go new file mode 100644 index 000000000..1f376f744 --- /dev/null +++ b/internal/blueprint/fsnode_customizations.go @@ -0,0 +1,285 @@ +package blueprint + +import ( + "encoding/json" + "fmt" + "os" + "regexp" + "strconv" + + "github.com/osbuild/osbuild-composer/internal/common" + "github.com/osbuild/osbuild-composer/internal/fsnode" +) + +// validateModeString checks that the given string is a valid mode octal number +func validateModeString(mode string) error { + // Check that the mode string matches the octal format regular expression. + // The leading is optional. + if regexp.MustCompile(`^[0]{0,1}[0-7]{3}$`).MatchString(mode) { + return nil + } + return fmt.Errorf("invalid mode %s: must be an octal number", mode) +} + +// DirectoryCustomization represents a directory to be created in the image +type DirectoryCustomization struct { + // Absolute path to the directory + Path string `json:"path" toml:"path"` + // Owner of the directory specified as a string (user name), int64 (UID) or nil + User interface{} `json:"user,omitempty" toml:"user,omitempty"` + // Owner of the directory specified as a string (group name), int64 (UID) or nil + Group interface{} `json:"group,omitempty" toml:"group,omitempty"` + // Permissions of the directory specified as an octal number + Mode string `json:"mode,omitempty" toml:"mode,omitempty"` + // EnsureParents ensures that all parent directories of the directory exist + EnsureParents bool `json:"ensure_parents,omitempty" toml:"ensure_parents,omitempty"` +} + +// Custom TOML unmarshalling for DirectoryCustomization with validation +func (d *DirectoryCustomization) UnmarshalTOML(data interface{}) error { + var dir DirectoryCustomization + + dataMap, _ := data.(map[string]interface{}) + + switch path := dataMap["path"].(type) { + case string: + dir.Path = path + default: + return fmt.Errorf("UnmarshalTOML: path must be a string") + } + + switch user := dataMap["user"].(type) { + case string: + dir.User = user + case int64: + dir.User = user + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: user must be a string or an integer, got %T", user) + } + + switch group := dataMap["group"].(type) { + case string: + dir.Group = group + case int64: + dir.Group = group + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: group must be a string or an integer") + } + + switch mode := dataMap["mode"].(type) { + case string: + dir.Mode = mode + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: mode must be a string") + } + + switch ensureParents := dataMap["ensure_parents"].(type) { + case bool: + dir.EnsureParents = ensureParents + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: ensure_parents must be a bool") + } + + // try converting to fsnode.Directory to validate all values + _, err := dir.ToFsNodeDirectory() + if err != nil { + return err + } + + *d = dir + return nil +} + +// Custom JSON unmarshalling for DirectoryCustomization with validation +func (d *DirectoryCustomization) UnmarshalJSON(data []byte) error { + type directoryCustomization DirectoryCustomization + + var dirPrivate directoryCustomization + if err := json.Unmarshal(data, &dirPrivate); err != nil { + return err + } + + dir := DirectoryCustomization(dirPrivate) + if uid, ok := dir.User.(float64); ok { + // check if uid can be converted to int64 + if uid != float64(int64(uid)) { + return fmt.Errorf("invalid user %f: must be an integer", uid) + } + dir.User = int64(uid) + } + if gid, ok := dir.Group.(float64); ok { + // check if gid can be converted to int64 + if gid != float64(int64(gid)) { + return fmt.Errorf("invalid group %f: must be an integer", gid) + } + dir.Group = int64(gid) + } + // try converting to fsnode.Directory to validate all values + _, err := dir.ToFsNodeDirectory() + if err != nil { + return err + } + + *d = dir + return nil +} + +// ToFsNodeDirectory converts the DirectoryCustomization to an fsnode.Directory +func (d DirectoryCustomization) ToFsNodeDirectory() (*fsnode.Directory, error) { + var mode *os.FileMode + if d.Mode != "" { + err := validateModeString(d.Mode) + if err != nil { + return nil, err + } + modeNum, err := strconv.ParseUint(d.Mode, 8, 32) + if err != nil { + return nil, fmt.Errorf("invalid mode %s: %v", d.Mode, err) + } + mode = common.ToPtr(os.FileMode(modeNum)) + } + + return fsnode.NewDirectory(d.Path, mode, d.User, d.Group, d.EnsureParents) +} + +// FileCustomization represents a file to be created in the image +type FileCustomization struct { + // Absolute path to the file + Path string `json:"path" toml:"path"` + // Owner of the directory specified as a string (user name), int64 (UID) or nil + User interface{} `json:"user,omitempty" toml:"user,omitempty"` + // Owner of the directory specified as a string (group name), int64 (UID) or nil + Group interface{} `json:"group,omitempty" toml:"group,omitempty"` + // Permissions of the file specified as an octal number + Mode string `json:"mode,omitempty" toml:"mode,omitempty"` + // Data is the file content in plain text + Data string `json:"data,omitempty" toml:"data,omitempty"` +} + +// Custom TOML unmarshalling for FileCustomization with validation +func (f *FileCustomization) UnmarshalTOML(data interface{}) error { + var file FileCustomization + + dataMap, _ := data.(map[string]interface{}) + + switch path := dataMap["path"].(type) { + case string: + file.Path = path + default: + return fmt.Errorf("UnmarshalTOML: path must be a string") + } + + switch user := dataMap["user"].(type) { + case string: + file.User = user + case int64: + file.User = user + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: user must be a string or an integer") + } + + switch group := dataMap["group"].(type) { + case string: + file.Group = group + case int64: + file.Group = group + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: group must be a string or an integer") + } + + switch mode := dataMap["mode"].(type) { + case string: + file.Mode = mode + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: mode must be a string") + } + + switch data := dataMap["data"].(type) { + case string: + file.Data = data + case nil: + break + default: + return fmt.Errorf("UnmarshalTOML: data must be a string") + } + + // try converting to fsnode.File to validate all values + _, err := file.ToFsNodeFile() + if err != nil { + return err + } + + *f = file + return nil +} + +// Custom JSON unmarshalling for FileCustomization with validation +func (f *FileCustomization) UnmarshalJSON(data []byte) error { + type fileCustomization FileCustomization + + var filePrivate fileCustomization + if err := json.Unmarshal(data, &filePrivate); err != nil { + return err + } + + file := FileCustomization(filePrivate) + if uid, ok := file.User.(float64); ok { + // check if uid can be converted to int64 + if uid != float64(int64(uid)) { + return fmt.Errorf("invalid user %f: must be an integer", uid) + } + file.User = int64(uid) + } + if gid, ok := file.Group.(float64); ok { + // check if gid can be converted to int64 + if gid != float64(int64(gid)) { + return fmt.Errorf("invalid group %f: must be an integer", gid) + } + file.Group = int64(gid) + } + // try converting to fsnode.File to validate all values + _, err := file.ToFsNodeFile() + if err != nil { + return err + } + + *f = file + return nil +} + +// ToFsNodeFile converts the FileCustomization to an fsnode.File +func (f FileCustomization) ToFsNodeFile() (*fsnode.File, error) { + var data []byte + if f.Data != "" { + data = []byte(f.Data) + } + + var mode *os.FileMode + if f.Mode != "" { + err := validateModeString(f.Mode) + if err != nil { + return nil, err + } + modeNum, err := strconv.ParseUint(f.Mode, 8, 32) + if err != nil { + return nil, fmt.Errorf("invalid mode %s: %v", f.Mode, err) + } + mode = common.ToPtr(os.FileMode(modeNum)) + } + + return fsnode.NewFile(f.Path, mode, f.User, f.Group, data) +} diff --git a/internal/blueprint/fsnode_customizations_test.go b/internal/blueprint/fsnode_customizations_test.go new file mode 100644 index 000000000..322aa3b26 --- /dev/null +++ b/internal/blueprint/fsnode_customizations_test.go @@ -0,0 +1,797 @@ +package blueprint + +import ( + "encoding/json" + "os" + "testing" + + "github.com/BurntSushi/toml" + "github.com/osbuild/osbuild-composer/internal/common" + "github.com/osbuild/osbuild-composer/internal/fsnode" + "github.com/stretchr/testify/assert" +) + +func TestDirectoryCustomizationToFsNodeDirectory(t *testing.T) { + ensureDirCreation := func(dir *fsnode.Directory, err error) *fsnode.Directory { + t.Helper() + assert.NoError(t, err) + assert.NotNil(t, dir) + return dir + } + + testCases := []struct { + Name string + Dir DirectoryCustomization + WantDir *fsnode.Directory + Error bool + }{ + { + Name: "empty", + Dir: DirectoryCustomization{}, + Error: true, + }, + { + Name: "path-only", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + }, + WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, nil, nil, false)), + }, + { + Name: "path-invalid", + Dir: DirectoryCustomization{ + Path: "etc/dir", + }, + Error: true, + }, + { + Name: "path-and-mode", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + Mode: "0700", + }, + WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", common.ToPtr(os.FileMode(0700)), nil, nil, false)), + }, + { + Name: "path-and-mode-no-leading-zero", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + Mode: "700", + }, + WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", common.ToPtr(os.FileMode(0700)), nil, nil, false)), + }, + { + Name: "path-and-mode-invalid", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + Mode: "12345", + }, + Error: true, + }, + { + Name: "path-user-group-string", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + User: "root", + Group: "root", + }, + WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, "root", "root", false)), + }, + { + Name: "path-user-group-int64", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + User: int64(0), + Group: int64(0), + }, + WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, int64(0), int64(0), false)), + }, + { + Name: "path-and-user-invalid-string", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + User: "r@@t", + }, + Error: true, + }, + { + Name: "path-and-user-invalid-int64", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + User: -1, + }, + Error: true, + }, + { + Name: "path-and-group-invalid-string", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + Group: "r@@t", + }, + Error: true, + }, + { + Name: "path-and-group-invalid-int64", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + Group: -1, + }, + Error: true, + }, + { + Name: "path-and-ensure-parent-dirs", + Dir: DirectoryCustomization{ + Path: "/etc/dir", + EnsureParents: true, + }, + WantDir: ensureDirCreation(fsnode.NewDirectory("/etc/dir", nil, nil, nil, true)), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + dir, err := tc.Dir.ToFsNodeDirectory() + if tc.Error { + assert.Error(t, err) + assert.Nil(t, dir) + } else { + assert.NoError(t, err) + assert.EqualValues(t, tc.WantDir, dir) + } + }) + } +} + +func TestDirectoryCustomizationUnmarshalTOML(t *testing.T) { + testCases := []struct { + Name string + TOML string + Want []DirectoryCustomization + Error bool + }{ + { + Name: "directory-with-path", + TOML: ` +name = "test" +description = "Test" +version = "0.0.0" + +[[customizations.directories]] +path = "/etc/dir" +`, + Want: []DirectoryCustomization{ + { + Path: "/etc/dir", + }, + }, + }, + { + Name: "multiple-directories", + TOML: ` +name = "test" +description = "Test" +version = "0.0.0" + +[[customizations.directories]] +path = "/etc/dir1" +mode = "0700" +user = "root" +group = "root" +ensure_parents = true + +[[customizations.directories]] +path = "/etc/dir2" +mode = "0755" +user = 0 +group = 0 +ensure_parents = true + +[[customizations.directories]] +path = "/etc/dir3" +`, + Want: []DirectoryCustomization{ + { + Path: "/etc/dir1", + Mode: "0700", + User: "root", + Group: "root", + EnsureParents: true, + }, + { + Path: "/etc/dir2", + Mode: "0755", + User: int64(0), + Group: int64(0), + EnsureParents: true, + }, + { + Path: "/etc/dir3", + }, + }, + }, + { + Name: "invalid-directories", + TOML: ` +name = "test" +description = "Test" +version = "0.0.0" + +[[customizations.directories]] +path = "/etc/../dir1" + +[[customizations.directories]] +path = "/etc/dir2" +mode = "12345" + +[[customizations.directories]] +path = "/etc/dir3" +user = "r@@t" + +[[customizations.directories]] +path = "/etc/dir4" +group = "r@@t" + +[[customizations.directories]] +path = "/etc/dir5" +user = -1 + +[[customizations.directories]] +path = "/etc/dir6" +group = -1 + + +[[customizations.directories]] +`, + Error: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + var blueprint Blueprint + err := toml.Unmarshal([]byte(tc.TOML), &blueprint) + if tc.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, blueprint.Customizations) + assert.Len(t, blueprint.Customizations.Directories, len(tc.Want)) + assert.EqualValues(t, tc.Want, blueprint.Customizations.GetDirectories()) + } + }) + } +} + +func TestDirectoryCustomizationUnmarshalJSON(t *testing.T) { + testCases := []struct { + Name string + JSON string + Want []DirectoryCustomization + Error bool + }{ + { + Name: "directory-with-path", + JSON: ` +{ + "name": "test", + "description": "Test", + "version": "0.0.0", + "customizations": { + "directories": [ + { + "path": "/etc/dir" + } + ] + } +}`, + Want: []DirectoryCustomization{ + { + Path: "/etc/dir", + }, + }, + }, + { + Name: "multiple-directories", + JSON: ` +{ + "name": "test", + "description": "Test", + "version": "0.0.0", + "customizations": { + "directories": [ + { + "path": "/etc/dir1", + "mode": "0700", + "user": "root", + "group": "root", + "ensure_parents": true + }, + { + "path": "/etc/dir2", + "mode": "0755", + "user": 0, + "group": 0, + "ensure_parents": true + }, + { + "path": "/etc/dir3" + } + ] + } +}`, + Want: []DirectoryCustomization{ + { + Path: "/etc/dir1", + Mode: "0700", + User: "root", + Group: "root", + EnsureParents: true, + }, + { + Path: "/etc/dir2", + Mode: "0755", + User: int64(0), + Group: int64(0), + EnsureParents: true, + }, + { + Path: "/etc/dir3", + }, + }, + }, + { + Name: "invalid-directories", + JSON: ` +{ + "name": "test", + "description": "Test", + "version": "0.0.0", + "customizations": { + "directories": [ + { + "path": "/etc/../dir1" + }, + { + "path": "/etc/dir2", + "mode": "12345" + }, + { + "path": "/etc/dir3", + "user": "r@@t" + }, + { + "path": "/etc/dir4", + "group": "r@@t" + }, + { + "path": "/etc/dir5", + "user": -1 + }, + { + "path": "/etc/dir6", + "group": -1 + } + {} + ] + } +}`, + Error: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + var blueprint Blueprint + err := json.Unmarshal([]byte(tc.JSON), &blueprint) + if tc.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, blueprint.Customizations) + assert.Len(t, blueprint.Customizations.Directories, len(tc.Want)) + assert.EqualValues(t, tc.Want, blueprint.Customizations.GetDirectories()) + } + }) + } + +} + +func TestFileCustomizationToFsNodeFile(t *testing.T) { + ensureFileCreation := func(file *fsnode.File, err error) *fsnode.File { + t.Helper() + assert.NoError(t, err) + assert.NotNil(t, file) + return file + } + + testCases := []struct { + Name string + File FileCustomization + Want *fsnode.File + Error bool + }{ + { + Name: "empty", + File: FileCustomization{}, + Error: true, + }, + { + Name: "path-only", + File: FileCustomization{ + Path: "/etc/file", + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, nil, nil)), + }, + { + Name: "path-invalid", + File: FileCustomization{ + Path: "../etc/file", + }, + Error: true, + }, + { + Name: "path-and-mode", + File: FileCustomization{ + Path: "/etc/file", + Mode: "0700", + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", common.ToPtr(os.FileMode(0700)), nil, nil, nil)), + }, + { + Name: "path-and-mode-no-leading-zero", + File: FileCustomization{ + Path: "/etc/file", + Mode: "700", + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", common.ToPtr(os.FileMode(0700)), nil, nil, nil)), + }, + { + Name: "path-and-mode-invalid", + File: FileCustomization{ + Path: "/etc/file", + Mode: "12345", + }, + Error: true, + }, + { + Name: "path-user-group-string", + File: FileCustomization{ + Path: "/etc/file", + User: "root", + Group: "root", + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, "root", "root", nil)), + }, + { + Name: "path-user-group-int64", + File: FileCustomization{ + Path: "/etc/file", + User: int64(0), + Group: int64(0), + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, int64(0), int64(0), nil)), + }, + { + Name: "path-and-user-invalid-string", + File: FileCustomization{ + Path: "/etc/file", + User: "r@@t", + }, + Error: true, + }, + { + Name: "path-and-user-invalid-int64", + File: FileCustomization{ + Path: "/etc/file", + User: int64(-1), + }, + Error: true, + }, + { + Name: "path-and-group-string", + File: FileCustomization{ + Path: "/etc/file", + Group: "root", + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, "root", nil)), + }, + { + Name: "path-and-group-int64", + File: FileCustomization{ + Path: "/etc/file", + Group: int64(0), + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, int64(0), nil)), + }, + { + Name: "path-and-group-invalid-string", + File: FileCustomization{ + Path: "/etc/file", + Group: "r@@t", + }, + Error: true, + }, + { + Name: "path-and-group-invalid-int64", + File: FileCustomization{ + Path: "/etc/file", + Group: int64(-1), + }, + Error: true, + }, + { + Name: "path-and-data", + File: FileCustomization{ + Path: "/etc/file", + Data: "hello world", + }, + Want: ensureFileCreation(fsnode.NewFile("/etc/file", nil, nil, nil, []byte("hello world"))), + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + file, err := tc.File.ToFsNodeFile() + if tc.Error { + assert.Error(t, err) + assert.Nil(t, file) + } else { + assert.NoError(t, err) + assert.EqualValues(t, tc.Want, file) + } + }) + } +} + +func TestFileCustomizationUnmarshalTOML(t *testing.T) { + testCases := []struct { + Name string + TOML string + Want []FileCustomization + Error bool + }{ + { + Name: "file-with-path", + TOML: ` +name = "test" +description = "Test" +version = "0.0.0" + +[[customizations.files]] +path = "/etc/file" +`, + Want: []FileCustomization{ + { + Path: "/etc/file", + }, + }, + }, + { + Name: "multiple-files", + TOML: ` +name = "test" +description = "Test" +version = "0.0.0" + +[[customizations.files]] +path = "/etc/file1" +mode = "0600" +user = "root" +group = "root" +data = "hello world" + +[[customizations.files]] +path = "/etc/file2" +mode = "0644" +data = "hello world 2" + +[[customizations.files]] +path = "/etc/file3" +user = 0 +group = 0 +data = "hello world 3" +`, + Want: []FileCustomization{ + { + Path: "/etc/file1", + Mode: "0600", + User: "root", + Group: "root", + Data: "hello world", + }, + { + Path: "/etc/file2", + Mode: "0644", + Data: "hello world 2", + }, + { + Path: "/etc/file3", + User: int64(0), + Group: int64(0), + Data: "hello world 3", + }, + }, + }, + { + Name: "invalid-files", + TOML: ` +name = "test" +description = "Test" +version = "0.0.0" + +[[customizations.files]] +path = "/etc/../file1" + +[[customizations.files]] +path = "/etc/file2" +mode = "12345" + +[[customizations.files]] +path = "/etc/file3" +user = "r@@t" + +[[customizations.files]] +path = "/etc/file4" +group = "r@@t" + +[[customizations.files]] +path = "/etc/file5" +user = -1 + +[[customizations.files]] +path = "/etc/file6" +group = -1 +`, + Error: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + var blueprint Blueprint + err := toml.Unmarshal([]byte(tc.TOML), &blueprint) + if tc.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, blueprint.Customizations) + assert.Len(t, blueprint.Customizations.Files, len(tc.Want)) + assert.EqualValues(t, tc.Want, blueprint.Customizations.Files) + } + }) + } +} + +func TestFileCustomizationUnmarshalJSON(t *testing.T) { + testCases := []struct { + Name string + JSON string + Want []FileCustomization + Error bool + }{ + { + Name: "file-with-path", + JSON: ` +{ + "name": "test", + "description": "Test", + "version": "0.0.0", + "customizations": { + "files": [ + { + "path": "/etc/file" + } + ] + } +}`, + Want: []FileCustomization{ + { + Path: "/etc/file", + }, + }, + }, + { + Name: "multiple-files", + JSON: ` +{ + "name": "test", + "description": "Test", + "version": "0.0.0", + "customizations": { + "files": [ + { + "path": "/etc/file1", + "mode": "0600", + "user": "root", + "group": "root", + "data": "hello world" + }, + { + "path": "/etc/file2", + "mode": "0644", + "data": "hello world 2" + }, + { + "path": "/etc/file3", + "user": 0, + "group": 0, + "data": "hello world 3" + } + ] + } +}`, + Want: []FileCustomization{ + { + Path: "/etc/file1", + Mode: "0600", + User: "root", + Group: "root", + Data: "hello world", + }, + { + Path: "/etc/file2", + Mode: "0644", + Data: "hello world 2", + }, + { + Path: "/etc/file3", + User: int64(0), + Group: int64(0), + Data: "hello world 3", + }, + }, + }, + { + Name: "invalid-files", + JSON: ` +{ + "name": "test", + "description": "Test", + "version": "0.0.0", + "customizations": { + "files": [ + { + "path": "/etc/../file1" + }, + { + "path": "/etc/file2", + "mode": "12345" + }, + { + "path": "/etc/file3", + "user": "r@@t" + }, + { + "path": "/etc/file4", + "group": "r@@t" + }, + { + "path": "/etc/file5", + "user": -1 + }, + { + "path": "/etc/file6", + "group": -1 + } + ] + } +}`, + Error: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + var blueprint Blueprint + err := json.Unmarshal([]byte(tc.JSON), &blueprint) + if tc.Error { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, blueprint.Customizations) + assert.Len(t, blueprint.Customizations.Files, len(tc.Want)) + assert.EqualValues(t, tc.Want, blueprint.Customizations.Files) + } + }) + } +}