Allow dots in app names (closes #260)
Check / check (pull_request) Skipped
Check / check (pull_request) Skipped
App names may now contain dots, such as sneak.berlin: runs of lowercase letters and numbers joined by single dots or by hyphens, 2 to 63 characters. Docker accepts every such name in the app's image name, upaas-<name>; a dot needs a letter or number on both sides because Docker requires it. The rule also keeps a dot off either end, so the name is safe as a directory and log file name. The new and edit app forms use the same pattern. Their old one was not a valid regular expression under the flag browsers compile it with, so browsers ignored it. A test creates an app named sneak.berlin through the new app form, then builds and deploys it against the fake Docker API and checks the image and container names with Docker's own rules. Model: opus-5-5
This commit is contained in:
@@ -13,12 +13,15 @@ const (
|
||||
appNameMaxLength = 63
|
||||
)
|
||||
|
||||
// validAppNameRe matches names containing only lowercase alphanumeric characters and
|
||||
// hyphens, starting and ending with an alphanumeric character.
|
||||
var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`)
|
||||
// validAppNameRe matches runs of lowercase letters and digits joined by
|
||||
// single dots or by hyphens, such as "my-app" or "sneak.berlin". Docker
|
||||
// accepts every name it allows as the app's image name, upaas-<name>; a
|
||||
// dot needs a letter or digit on both sides because Docker requires it.
|
||||
// It also keeps the name from being "." or ".." or starting or ending
|
||||
// with a dot, so it is safe as a directory and file name. The pattern
|
||||
// attribute of the name field on the new and edit app forms is the same.
|
||||
var validAppNameRe = regexp.MustCompile(`^[a-z0-9]+((\.|-+)[a-z0-9]+)*$`)
|
||||
|
||||
// validateAppName checks that the given app name is safe for use in Docker
|
||||
// container names, image tags, and file system paths.
|
||||
var (
|
||||
errAppNameLength = errors.New(
|
||||
"app name must be between " +
|
||||
@@ -26,11 +29,14 @@ var (
|
||||
strconv.Itoa(appNameMaxLength) + " characters",
|
||||
)
|
||||
errAppNamePattern = errors.New(
|
||||
"app name must contain only lowercase letters, numbers, " +
|
||||
"and hyphens, and must start and end with a letter or number",
|
||||
"app name must contain only lowercase letters, numbers, hyphens, " +
|
||||
"and dots, must start and end with a letter or number, " +
|
||||
"and must have a letter or number on both sides of each dot",
|
||||
)
|
||||
)
|
||||
|
||||
// validateAppName checks that the given app name is safe for use in Docker
|
||||
// container names, image tags, and file system paths.
|
||||
func validateAppName(name string) error {
|
||||
if len(name) < appNameMinLength || len(name) > appNameMaxLength {
|
||||
return errAppNameLength
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
package handlers //nolint:testpackage // testing unexported validateAppName
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"sneak.berlin/go/upaas/internal/models"
|
||||
"sneak.berlin/go/upaas/templates"
|
||||
)
|
||||
|
||||
func TestValidateAppName(t *testing.T) {
|
||||
@@ -18,6 +27,10 @@ func TestValidateAppName(t *testing.T) {
|
||||
{"valid two chars", "ab", false},
|
||||
{"valid complex", "my-cool-app-v2", false},
|
||||
{"valid all numbers", "123", false},
|
||||
{"valid double hyphen", "my--app", false},
|
||||
{"valid domain", "sneak.berlin", false},
|
||||
{"valid two dots", "www.sneak.berlin", false},
|
||||
{"valid dot and hyphen", "my-app.example.com", false},
|
||||
{"empty", "", true},
|
||||
{"single char", "a", true},
|
||||
{"too long", "a" + string(make([]byte, 63)), true},
|
||||
@@ -36,7 +49,12 @@ func TestValidateAppName(t *testing.T) {
|
||||
{"starts with hyphen", "-myapp", true},
|
||||
{"ends with hyphen", "myapp-", true},
|
||||
{"underscore", "my_app", true},
|
||||
{"dot", "my.app", true},
|
||||
{"two dots in a row", "a..b", true},
|
||||
{"starts with dot", ".a", true},
|
||||
{"ends with dot", "a.", true},
|
||||
{"only dots", "..", true},
|
||||
{"hyphen before dot", "a-.b", true},
|
||||
{"hyphen after dot", "a.-b", true},
|
||||
{"slash", "my/app", true},
|
||||
{"path traversal", "../etc/passwd", true},
|
||||
{"special chars", "app@name!", true},
|
||||
@@ -54,3 +72,42 @@ func TestValidateAppName(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAppNameErrorMentionsDots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateAppName("a..b")
|
||||
|
||||
require.ErrorIs(t, err, errAppNamePattern)
|
||||
assert.Contains(t, err.Error(), "dots")
|
||||
}
|
||||
|
||||
// TestAppFormsCheckAppNameLikeServer checks that the name field on the new
|
||||
// and edit app forms has the server's pattern and length limits, and that
|
||||
// its hint mentions dots.
|
||||
func TestAppFormsCheckAppNameLikeServer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pattern := strings.TrimSuffix(strings.TrimPrefix(validAppNameRe.String(), "^"), "$")
|
||||
|
||||
pages := map[string]map[string]any{
|
||||
"app_new.html": {},
|
||||
"app_edit.html": {dataKeyApp: &models.App{}},
|
||||
}
|
||||
|
||||
for page, data := range pages {
|
||||
var out bytes.Buffer
|
||||
|
||||
require.NoError(t, templates.GetParsed().ExecuteTemplate(&out, page, data))
|
||||
|
||||
// The name field and its hint, up to the end of their div.
|
||||
_, field, found := strings.Cut(out.String(), `id="name"`)
|
||||
require.True(t, found, page)
|
||||
|
||||
field, _, _ = strings.Cut(field, "</div>")
|
||||
assert.Contains(t, field, `pattern="`+pattern+`"`, page)
|
||||
assert.Contains(t, field, `minlength="`+strconv.Itoa(appNameMinLength)+`"`, page)
|
||||
assert.Contains(t, field, `maxlength="`+strconv.Itoa(appNameMaxLength)+`"`, page)
|
||||
assert.Contains(t, field, "hyphens, and dots", page)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user