1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package main

import (
	"fmt"
	"strings"
)

// =============================================================================
// app names
// =============================================================================

// maxAppNameLen is the DNS label limit (RFC 1035 §2.3.4). An app name becomes a
// subdomain label, so it inherits the label limit rather than the 253-byte
// limit on a whole name.
const maxAppNameLen = 63

// reservedAppNames are names an app may not take because something else already
// answers to them once apps are published as <name>.<baseDomain>.
var reservedAppNames = map[string]bool{
	"www":   true,
	"admin": true,
	"api":   true,
	// Siblings of the app directories inside a state dir. An app by either name
	// would collide with the extracted image trees (images/<name>) or with the
	// environment database file itself (hosty.db — reserved as "hosty" below,
	// but listed here in spirit: the state dir is a namespace shared between
	// app directories and hosty's own entries).
	"images": true,
	// hosty's own future admin UI (see DESIGN.md, Phase 3). Reserving it here
	// only blocks *user* installs: Phase 3 would claim the name from inside
	// hosty, which does not go through this check.
	"hosty": true,
}

// validateAppName enforces that an app name is a valid DNS label.
//
// The name is not just a label for humans: it is simultaneously a directory
// name under the state dir, a systemd unit name, the PORTABLE_PREFIXES value
// baked into the image, and (from Phase 2) a subdomain label. Restricting it to
// what a DNS label may contain is what keeps those four from ever disagreeing.
//
// Uppercase is rejected rather than normalised to lowercase. DNS is
// case-insensitive but Linux paths are not, so accepting "MyApp" would make it
// one subdomain but two images — the same aliasing that made is-attached
// misreport which app it was talking about (see DESIGN.md, Phase 1c).
//
// Callers must run this *before* creating any state, so that a rejected name
// cannot leave a half-installed app behind.
func validateAppName(name string) error {
	if name == "" {
		return fmt.Errorf("app name must not be empty")
	}
	if len(name) > maxAppNameLen {
		return fmt.Errorf("app name %q is %d characters, but a DNS label may be at most %d",
			name, len(name), maxAppNameLen)
	}
	for i := 0; i < len(name); i++ {
		c := name[i]
		switch {
		case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
			// fine anywhere
		case c == '-':
			if i == 0 || i == len(name)-1 {
				return fmt.Errorf("app name %q must not start or end with a dash", name)
			}
		case c >= 'A' && c <= 'Z':
			return fmt.Errorf(
				"app name %q must be lowercase (DNS is case-insensitive but file paths are not, so %q and its lowercase form would be one subdomain but two images)",
				name, name)
		default:
			return fmt.Errorf(
				"app name %q contains %q; only lowercase letters, digits and inner dashes are allowed",
				name, string(c))
		}
	}
	// hosty gives every app a companion FUSE unit called <name>-fs.service, so
	// an app literally named "<something>-fs" would collide with the companion
	// unit of the app "<something>".
	if strings.HasSuffix(name, "-fs") {
		return fmt.Errorf(
			"app name %q must not end in -fs: that would collide with the companion unit %s.service of the app %q",
			name, name, strings.TrimSuffix(name, "-fs"))
	}
	if reservedAppNames[name] {
		return fmt.Errorf("app name %q is reserved", name)
	}
	return nil
}