Skip to content

Commit 5fbacba

Browse files
committed
image name validation
1 parent d39fdca commit 5fbacba

File tree

5 files changed

+130
-15
lines changed

5 files changed

+130
-15
lines changed

cmd/api/api/images.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ func (s *ApiService) CreateImage(ctx context.Context, request oapi.CreateImageRe
3131
img, err := s.ImageManager.CreateImage(ctx, *request.Body)
3232
if err != nil {
3333
switch {
34+
case images.IsInvalidNameError(err):
35+
return oapi.CreateImage400JSONResponse{
36+
Code: "invalid_name",
37+
Message: err.Error(),
38+
}, nil
3439
case errors.Is(err, images.ErrAlreadyExists):
3540
return oapi.CreateImage400JSONResponse{
3641
Code: "already_exists",
@@ -53,7 +58,7 @@ func (s *ApiService) GetImage(ctx context.Context, request oapi.GetImageRequestO
5358
img, err := s.ImageManager.GetImage(ctx, request.Name)
5459
if err != nil {
5560
switch {
56-
case errors.Is(err, images.ErrNotFound):
61+
case images.IsInvalidNameError(err), errors.Is(err, images.ErrNotFound):
5762
return oapi.GetImage404JSONResponse{
5863
Code: "not_found",
5964
Message: "image not found",
@@ -75,7 +80,7 @@ func (s *ApiService) DeleteImage(ctx context.Context, request oapi.DeleteImageRe
7580
err := s.ImageManager.DeleteImage(ctx, request.Name)
7681
if err != nil {
7782
switch {
78-
case errors.Is(err, images.ErrNotFound):
83+
case images.IsInvalidNameError(err), errors.Is(err, images.ErrNotFound):
7984
return oapi.DeleteImage404JSONResponse{
8085
Code: "not_found",
8186
Message: "image not found",

cmd/api/api/images_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,30 @@ func TestCreateImage_InvalidTag(t *testing.T) {
169169
t.Fatal("Build did not fail within timeout")
170170
}
171171

172+
func TestCreateImage_InvalidName(t *testing.T) {
173+
svc := newTestService(t)
174+
ctx := ctx()
175+
176+
invalidNames := []string{
177+
"invalid::",
178+
"has spaces",
179+
"",
180+
}
181+
182+
for _, name := range invalidNames {
183+
t.Run(name, func(t *testing.T) {
184+
createResp, err := svc.CreateImage(ctx, oapi.CreateImageRequestObject{
185+
Body: &oapi.CreateImageRequest{Name: name},
186+
})
187+
require.NoError(t, err)
188+
189+
badReq, ok := createResp.(oapi.CreateImage400JSONResponse)
190+
require.True(t, ok, "expected 400 bad request for invalid name: %s", name)
191+
require.Equal(t, "invalid_name", badReq.Code)
192+
})
193+
}
194+
}
195+
172196
func getQueuePos(pos *int) int {
173197
if pos == nil {
174198
return 0

lib/images/errors.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
package images
22

3-
import "errors"
3+
import (
4+
"errors"
5+
"strings"
6+
)
47

58
var (
6-
ErrNotFound = errors.New("image not found")
7-
ErrAlreadyExists = errors.New("image already exists")
8-
ErrInvalidImage = errors.New("invalid image reference")
9-
ErrConversionFailed = errors.New("failed to convert image")
9+
ErrNotFound = errors.New("image not found")
10+
ErrAlreadyExists = errors.New("image already exists")
1011
)
1112

13+
// IsInvalidNameError checks if an error is due to invalid image name
14+
func IsInvalidNameError(err error) bool {
15+
if err == nil {
16+
return false
17+
}
18+
return strings.Contains(err.Error(), "invalid image name") ||
19+
strings.Contains(err.Error(), "invalid reference format")
20+
}

lib/images/manager.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sort"
99
"time"
1010

11+
"github.com/distribution/reference"
1112
"github.com/onkernel/hypeman/lib/oapi"
1213
)
1314

@@ -60,23 +61,28 @@ func (m *manager) ListImages(ctx context.Context) ([]oapi.Image, error) {
6061
}
6162

6263
func (m *manager) CreateImage(ctx context.Context, req oapi.CreateImageRequest) (*oapi.Image, error) {
63-
if imageExists(m.dataDir, req.Name) {
64+
normalizedName, err := normalizeImageName(req.Name)
65+
if err != nil {
66+
return nil, fmt.Errorf("invalid image name: %w", err)
67+
}
68+
69+
if imageExists(m.dataDir, normalizedName) {
6470
return nil, ErrAlreadyExists
6571
}
6672

6773
meta := &imageMetadata{
68-
Name: req.Name,
74+
Name: normalizedName,
6975
Status: StatusPending,
7076
Request: &req,
7177
CreatedAt: time.Now(),
7278
}
7379

74-
if err := writeMetadata(m.dataDir, req.Name, meta); err != nil {
80+
if err := writeMetadata(m.dataDir, normalizedName, meta); err != nil {
7581
return nil, fmt.Errorf("write initial metadata: %w", err)
7682
}
7783

78-
queuePos := m.queue.Enqueue(req.Name, req, func() {
79-
m.buildImage(context.Background(), req.Name, req)
84+
queuePos := m.queue.Enqueue(normalizedName, req, func() {
85+
m.buildImage(context.Background(), normalizedName, req)
8086
})
8187

8288
img := meta.toOAPI()
@@ -179,22 +185,45 @@ func (m *manager) RecoverInterruptedBuilds() {
179185
}
180186

181187
func (m *manager) GetImage(ctx context.Context, name string) (*oapi.Image, error) {
182-
meta, err := readMetadata(m.dataDir, name)
188+
normalizedName, err := normalizeImageName(name)
189+
if err != nil {
190+
return nil, fmt.Errorf("invalid image name: %w", err)
191+
}
192+
193+
meta, err := readMetadata(m.dataDir, normalizedName)
183194
if err != nil {
184195
return nil, err
185196
}
186197

187198
img := meta.toOAPI()
188199

189200
if meta.Status == StatusPending {
190-
img.QueuePosition = m.queue.GetPosition(name)
201+
img.QueuePosition = m.queue.GetPosition(normalizedName)
191202
}
192203

193204
return img, nil
194205
}
195206

196207
func (m *manager) DeleteImage(ctx context.Context, name string) error {
197-
return deleteImage(m.dataDir, name)
208+
normalizedName, err := normalizeImageName(name)
209+
if err != nil {
210+
return fmt.Errorf("invalid image name: %w", err)
211+
}
212+
return deleteImage(m.dataDir, normalizedName)
213+
}
214+
215+
// normalizeImageName validates and normalizes an OCI image reference
216+
// Examples: alpine → docker.io/library/alpine:latest
217+
// nginx:1.0 → docker.io/library/nginx:1.0
218+
func normalizeImageName(name string) (string, error) {
219+
named, err := reference.ParseNormalizedNamed(name)
220+
if err != nil {
221+
return "", err
222+
}
223+
224+
// Ensure it has a tag (add :latest if missing)
225+
tagged := reference.TagNameOnly(named)
226+
return tagged.String(), nil
198227
}
199228

200229

lib/images/validation_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package images
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestNormalizeImageName(t *testing.T) {
10+
tests := []struct {
11+
input string
12+
expected string
13+
wantErr bool
14+
}{
15+
// Valid images with full reference
16+
{"docker.io/library/alpine:latest", "docker.io/library/alpine:latest", false},
17+
{"ghcr.io/myorg/myapp:v1.0.0", "ghcr.io/myorg/myapp:v1.0.0", false},
18+
19+
// Shorthand (gets expanded)
20+
{"alpine", "docker.io/library/alpine:latest", false},
21+
{"alpine:3.18", "docker.io/library/alpine:3.18", false},
22+
{"nginx", "docker.io/library/nginx:latest", false},
23+
{"nginx:alpine", "docker.io/library/nginx:alpine", false},
24+
25+
// Without tag (gets :latest added)
26+
{"docker.io/library/alpine", "docker.io/library/alpine:latest", false},
27+
{"ubuntu", "docker.io/library/ubuntu:latest", false},
28+
29+
// Invalid
30+
{"", "", true},
31+
{"invalid::", "", true},
32+
{"has spaces", "", true},
33+
{"UPPERCASE", "", true}, // Repository names must be lowercase
34+
}
35+
36+
for _, tt := range tests {
37+
t.Run(tt.input, func(t *testing.T) {
38+
result, err := normalizeImageName(tt.input)
39+
if tt.wantErr {
40+
require.Error(t, err)
41+
} else {
42+
require.NoError(t, err)
43+
require.Equal(t, tt.expected, result)
44+
}
45+
})
46+
}
47+
}
48+

0 commit comments

Comments
 (0)