Skip to content

Commit 5dec822

Browse files
committed
handler_blueprints: validate files, dir and mountpoint customizations
For direct feedback before creating or updating a blueprint, we'll do basic checks that will fail later on, anyway. Merges validation for create and update. Deduplicates code, to be prepared for more input validation.
1 parent 64daa66 commit 5dec822

File tree

2 files changed

+366
-43
lines changed

2 files changed

+366
-43
lines changed

internal/v1/blueprint_rules.go

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
package v1
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"strconv"
9+
10+
"github.com/labstack/echo/v4"
11+
"github.com/osbuild/images/pkg/customizations/fsnode"
12+
)
13+
14+
var (
15+
blueprintNameRegex = regexp.MustCompile(`\S+`)
16+
blueprintInvalidNameDetail = "the blueprint name must contain at least two characters"
17+
)
18+
19+
// validationError wraps an error with HTTP response details
20+
type validationError struct {
21+
title string
22+
detail string
23+
}
24+
25+
func (e ValidationError) Error() string {
26+
if len(e.HTTPErrorList.Errors) > 0 {
27+
return e.HTTPErrorList.Errors[0].Detail
28+
}
29+
return "validation error"
30+
}
31+
32+
func newValidationError(title, detail string) error {
33+
return validationError{
34+
title: title,
35+
detail: detail,
36+
}
37+
}
38+
39+
// parseOctalMode parses an octal mode string to os.FileMode
40+
func parseOctalMode(modeStr *string) *os.FileMode {
41+
if modeStr == nil {
42+
return nil
43+
}
44+
45+
if modeVal, err := strconv.ParseUint(*modeStr, 8, 32); err == nil {
46+
m := os.FileMode(modeVal)
47+
return &m
48+
}
49+
return nil
50+
}
51+
52+
// parseFileUser extracts user from File union type
53+
func parseFileUser(fileUser *File_User) interface{} {
54+
if fileUser == nil {
55+
return nil
56+
}
57+
58+
if userStr, err := fileUser.AsFileUser0(); err == nil {
59+
return userStr
60+
} else if userInt, err := fileUser.AsFileUser1(); err == nil {
61+
return userInt
62+
}
63+
return nil
64+
}
65+
66+
// parseFileGroup extracts group from File union type
67+
func parseFileGroup(fileGroup *File_Group) interface{} {
68+
if fileGroup == nil {
69+
return nil
70+
}
71+
72+
if groupStr, err := fileGroup.AsFileGroup0(); err == nil {
73+
return groupStr
74+
} else if groupInt, err := fileGroup.AsFileGroup1(); err == nil {
75+
return groupInt
76+
}
77+
return nil
78+
}
79+
80+
// parseDirectoryUser extracts user from Directory union type
81+
func parseDirectoryUser(dirUser *Directory_User) interface{} {
82+
if dirUser == nil {
83+
return nil
84+
}
85+
86+
if userStr, err := dirUser.AsDirectoryUser0(); err == nil {
87+
return userStr
88+
} else if userInt, err := dirUser.AsDirectoryUser1(); err == nil {
89+
return userInt
90+
}
91+
return nil
92+
}
93+
94+
// parseDirectoryGroup extracts group from Directory union type
95+
func parseDirectoryGroup(dirGroup *Directory_Group) interface{} {
96+
if dirGroup == nil {
97+
return nil
98+
}
99+
100+
if groupStr, err := dirGroup.AsDirectoryGroup0(); err == nil {
101+
return groupStr
102+
} else if groupInt, err := dirGroup.AsDirectoryGroup1(); err == nil {
103+
return groupInt
104+
}
105+
return nil
106+
}
107+
108+
// BlueprintValidator interface for the Chain of Responsibility pattern
109+
type BlueprintValidator interface {
110+
Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error
111+
}
112+
113+
// ValidationChain manages a chain of blueprint validators
114+
type ValidationChain struct {
115+
validators []BlueprintValidator
116+
}
117+
118+
// Validate executes all validators in the chain and collects all errors
119+
func (vc *ValidationChain) Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error {
120+
var allErrors []HTTPError
121+
122+
for _, validator := range vc.validators {
123+
if err := validator.Validate(ctx, request, existingUsers); err != nil {
124+
if validationErr, ok := err.(ValidationError); ok {
125+
// Collect all errors from this validator
126+
allErrors = append(allErrors, validationErr.HTTPErrorList.Errors...)
127+
} else {
128+
// Handle unexpected error types
129+
allErrors = append(allErrors, HTTPError{
130+
Title: "Validation Error",
131+
Detail: err.Error(),
132+
})
133+
}
134+
}
135+
}
136+
137+
if len(allErrors) > 0 {
138+
return ValidationError{
139+
HTTPErrorList: HTTPErrorList{
140+
Errors: allErrors,
141+
},
142+
}
143+
}
144+
145+
return nil
146+
}
147+
148+
// NameValidator validates blueprint names
149+
type NameValidator struct{}
150+
151+
func (nv *NameValidator) Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error {
152+
if !blueprintNameRegex.MatchString(request.Name) {
153+
return newValidationError("Invalid blueprint name", blueprintInvalidNameDetail)
154+
}
155+
return nil
156+
}
157+
158+
// UserValidator validates blueprint users
159+
type UserValidator struct{}
160+
161+
func (uv *UserValidator) Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error {
162+
users := request.Customizations.Users
163+
if users == nil {
164+
return nil
165+
}
166+
167+
for i, user := range *users {
168+
var err error
169+
if existingUsers != nil {
170+
err = (*users)[i].MergeForUpdate(existingUsers)
171+
} else {
172+
err = user.Valid()
173+
}
174+
175+
if err != nil {
176+
return newValidationError("Invalid user", err.Error())
177+
}
178+
}
179+
return nil
180+
}
181+
182+
// FileValidator validates blueprint file customizations
183+
type FileValidator struct{}
184+
185+
func (fv *FileValidator) Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error {
186+
files := request.Customizations.Files
187+
if files == nil {
188+
return nil
189+
}
190+
191+
for _, file := range *files {
192+
// Convert API types to fsnode types for validation
193+
mode := parseOctalMode(file.Mode)
194+
user := parseFileUser(file.User)
195+
group := parseFileGroup(file.Group)
196+
197+
var data []byte
198+
if file.Data != nil {
199+
data = []byte(*file.Data)
200+
}
201+
202+
// Use fsnode.NewFile for validation - this handles all path, mode, user, group validation
203+
_, err := fsnode.NewFile(file.Path, mode, user, group, data)
204+
if err != nil {
205+
return newValidationError("Invalid file customization", fmt.Sprintf("file %q: %s", file.Path, err.Error()))
206+
}
207+
}
208+
return nil
209+
}
210+
211+
// DirectoryValidator validates blueprint directory customizations
212+
type DirectoryValidator struct{}
213+
214+
func (dv *DirectoryValidator) Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error {
215+
directories := request.Customizations.Directories
216+
if directories == nil {
217+
return nil
218+
}
219+
220+
for _, dir := range *directories {
221+
// Convert API types to fsnode types for validation
222+
mode := parseOctalMode(dir.Mode)
223+
user := parseDirectoryUser(dir.User)
224+
group := parseDirectoryGroup(dir.Group)
225+
226+
ensureParents := false
227+
if dir.EnsureParents != nil {
228+
ensureParents = *dir.EnsureParents
229+
}
230+
231+
// Use fsnode.NewDirectory for validation - this handles all path, mode, user, group validation
232+
_, err := fsnode.NewDirectory(dir.Path, mode, user, group, ensureParents)
233+
if err != nil {
234+
return newValidationError("Invalid directory customization", fmt.Sprintf("directory %q: %s", dir.Path, err.Error()))
235+
}
236+
}
237+
return nil
238+
}
239+
240+
// FilesystemValidator validates blueprint filesystem customizations
241+
type FilesystemValidator struct{}
242+
243+
func (fsv *FilesystemValidator) Validate(ctx echo.Context, request *CreateBlueprintRequest, existingUsers []User) error {
244+
filesystem := request.Customizations.Filesystem
245+
if filesystem == nil {
246+
return nil
247+
}
248+
249+
for _, fs := range *filesystem {
250+
// Use the same path validation logic as fsnode (following library patterns)
251+
if fs.Mountpoint == "" {
252+
return newValidationError("Invalid filesystem customization", "mountpoint must not be empty")
253+
}
254+
if fs.Mountpoint[0] != '/' {
255+
return newValidationError("Invalid filesystem customization", fmt.Sprintf("mountpoint %q must be absolute", fs.Mountpoint))
256+
}
257+
if fs.Mountpoint != filepath.Clean(fs.Mountpoint) {
258+
return newValidationError("Invalid filesystem customization", fmt.Sprintf("mountpoint %q must be canonical", fs.Mountpoint))
259+
}
260+
261+
// Validate minimum size is reasonable
262+
if fs.MinSize > 0 && fs.MinSize < 1024*1024 { // 1MB minimum
263+
return newValidationError("Invalid filesystem customization", fmt.Sprintf("mountpoint %q minimum size must be at least 1MB", fs.Mountpoint))
264+
}
265+
}
266+
return nil
267+
}
268+
269+
// NewBlueprintValidationChain creates a new validation chain with all validators
270+
func NewBlueprintValidationChain() *ValidationChain {
271+
return &ValidationChain{
272+
validators: []BlueprintValidator{
273+
&NameValidator{},
274+
&UserValidator{},
275+
&FileValidator{},
276+
&DirectoryValidator{},
277+
&FilesystemValidator{},
278+
},
279+
}
280+
}
281+
282+
// ValidateBlueprintRequest performs common validation for blueprint requests
283+
// using the Chain of Responsibility pattern
284+
func ValidateBlueprintRequest(ctx echo.Context, blueprintRequest *CreateBlueprintRequest, existingUsers []User) error {
285+
chain := NewBlueprintValidationChain()
286+
return chain.Validate(ctx, blueprintRequest, existingUsers)
287+
}

0 commit comments

Comments
 (0)