Add helper functions for checking directory and file blueprint customizations against the policy of allowed paths. These functions are not yet used in the distro definitions. Signed-off-by: Tomáš Hozza <thozza@redhat.com>
471 lines
13 KiB
Go
471 lines
13 KiB
Go
package blueprint
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/osbuild/osbuild-composer/internal/common"
|
|
"github.com/osbuild/osbuild-composer/internal/fsnode"
|
|
"github.com/osbuild/osbuild-composer/internal/pathpolicy"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// DirectoryCustomizationsToFsNodeDirectories converts a slice of DirectoryCustomizations
|
|
// to a slice of fsnode.Directories
|
|
func DirectoryCustomizationsToFsNodeDirectories(dirs []DirectoryCustomization) ([]*fsnode.Directory, error) {
|
|
if len(dirs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var fsDirs []*fsnode.Directory
|
|
var errors []error
|
|
for _, dir := range dirs {
|
|
fsDir, err := dir.ToFsNodeDirectory()
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
fsDirs = append(fsDirs, fsDir)
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return nil, fmt.Errorf("invalid directory customizations: %v", errors)
|
|
}
|
|
|
|
return fsDirs, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// FileCustomizationsToFsNodeFiles converts a slice of FileCustomization to a slice of *fsnode.File
|
|
func FileCustomizationsToFsNodeFiles(files []FileCustomization) ([]*fsnode.File, error) {
|
|
if len(files) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var fsFiles []*fsnode.File
|
|
var errors []error
|
|
for _, file := range files {
|
|
fsFile, err := file.ToFsNodeFile()
|
|
if err != nil {
|
|
errors = append(errors, err)
|
|
}
|
|
fsFiles = append(fsFiles, fsFile)
|
|
}
|
|
|
|
if len(errors) > 0 {
|
|
return nil, fmt.Errorf("invalid file customizations: %v", errors)
|
|
}
|
|
|
|
return fsFiles, nil
|
|
}
|
|
|
|
// ValidateDirFileCustomizations validates the given Directory and File customizations.
|
|
// If the customizations are invalid, an error is returned. Otherwise, nil is returned.
|
|
//
|
|
// It currently ensures that:
|
|
// - No file path is a prefix of another file or directory path
|
|
// - There are no duplicate file or directory paths in the customizations
|
|
func ValidateDirFileCustomizations(dirs []DirectoryCustomization, files []FileCustomization) error {
|
|
fsNodesMap := make(map[string]interface{}, len(dirs)+len(files))
|
|
nodesPaths := make([]string, 0, len(dirs)+len(files))
|
|
|
|
// First check for duplicate paths
|
|
duplicatePaths := make([]string, 0)
|
|
for _, dir := range dirs {
|
|
if _, ok := fsNodesMap[dir.Path]; ok {
|
|
duplicatePaths = append(duplicatePaths, dir.Path)
|
|
}
|
|
fsNodesMap[dir.Path] = dir
|
|
nodesPaths = append(nodesPaths, dir.Path)
|
|
}
|
|
|
|
for _, file := range files {
|
|
if _, ok := fsNodesMap[file.Path]; ok {
|
|
duplicatePaths = append(duplicatePaths, file.Path)
|
|
}
|
|
fsNodesMap[file.Path] = file
|
|
nodesPaths = append(nodesPaths, file.Path)
|
|
}
|
|
|
|
// There is no point in continuing if there are duplicate paths,
|
|
// since the fsNodesMap will not be valid.
|
|
if len(duplicatePaths) > 0 {
|
|
return fmt.Errorf("duplicate files / directory customization paths: %v", duplicatePaths)
|
|
}
|
|
|
|
invalidFSNodes := make([]string, 0)
|
|
checkedPaths := make(map[string]bool)
|
|
// Sort the paths so that we always check the longest paths first. This
|
|
// ensures that we don't check a parent path before we check the child
|
|
// path. Reverse sort the slice based on directory depth.
|
|
sort.Slice(nodesPaths, func(i, j int) bool {
|
|
return strings.Count(nodesPaths[i], "/") > strings.Count(nodesPaths[j], "/")
|
|
})
|
|
|
|
for _, nodePath := range nodesPaths {
|
|
// Skip paths that we have already checked
|
|
if checkedPaths[nodePath] {
|
|
continue
|
|
}
|
|
|
|
// Check all parent paths of the current path. If any of them have
|
|
// already been checked, then we do not need to check them again.
|
|
// This is because we always check the longest paths first. If a parent
|
|
// path exists in the filesystem nodes map and it is a File,
|
|
// then it is an error because it is a parent of a Directory or File.
|
|
// Parent paths can be only Directories.
|
|
parentPath := nodePath
|
|
for {
|
|
parentPath = path.Dir(parentPath)
|
|
|
|
// "." is returned only when the path is relative and we reached
|
|
// the root directory. This should never happen because File
|
|
// and Directory customization paths are validated as part of
|
|
// the unmarshalling process from JSON and TOML.
|
|
if parentPath == "." {
|
|
panic("filesystem node has relative path set.")
|
|
}
|
|
|
|
if parentPath == "/" {
|
|
break
|
|
}
|
|
|
|
if checkedPaths[parentPath] {
|
|
break
|
|
}
|
|
|
|
// If the node is not a Directory, then it is an error because
|
|
// it is a parent of a Directory or File.
|
|
if node, ok := fsNodesMap[parentPath]; ok {
|
|
switch node.(type) {
|
|
case DirectoryCustomization:
|
|
break
|
|
case FileCustomization:
|
|
invalidFSNodes = append(invalidFSNodes, nodePath)
|
|
default:
|
|
panic(fmt.Sprintf("unexpected filesystem node customization type: %T", node))
|
|
}
|
|
}
|
|
|
|
checkedPaths[parentPath] = true
|
|
}
|
|
|
|
checkedPaths[nodePath] = true
|
|
}
|
|
|
|
if len(invalidFSNodes) > 0 {
|
|
return fmt.Errorf("the following filesystem nodes are parents of another node and are not directories: %s", invalidFSNodes)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckFileCustomizationsPolicy checks if the given File customizations are allowed by the path policy.
|
|
// If any of the customizations are not allowed by the path policy, an error is returned. Otherwise, nil is returned.
|
|
func CheckFileCustomizationsPolicy(files []FileCustomization, pathPolicy *pathpolicy.PathPolicies) error {
|
|
var invalidPaths []string
|
|
for _, file := range files {
|
|
if err := pathPolicy.Check(file.Path); err != nil {
|
|
invalidPaths = append(invalidPaths, file.Path)
|
|
}
|
|
}
|
|
|
|
if len(invalidPaths) > 0 {
|
|
return fmt.Errorf("the following custom files are not allowed: %+q", invalidPaths)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// CheckDirectoryCustomizationsPolicy checks if the given Directory customizations are allowed by the path policy.
|
|
// If any of the customizations are not allowed by the path policy, an error is returned. Otherwise, nil is returned.
|
|
func CheckDirectoryCustomizationsPolicy(dirs []DirectoryCustomization, pathPolicy *pathpolicy.PathPolicies) error {
|
|
var invalidPaths []string
|
|
for _, dir := range dirs {
|
|
if err := pathPolicy.Check(dir.Path); err != nil {
|
|
invalidPaths = append(invalidPaths, dir.Path)
|
|
}
|
|
}
|
|
|
|
if len(invalidPaths) > 0 {
|
|
return fmt.Errorf("the following custom directories are not allowed: %+q", invalidPaths)
|
|
}
|
|
|
|
return nil
|
|
}
|