1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "errors"
7 "net/url"
8 "strings"
9 "time"
10 "unicode"
11 "unicode/utf8"
12)
13
14// Secrets, variables and environments (ADR 0019, CI-3 plan D2).
15
16// Limits of names and values.
17const (
18 // MaxSettingValueBytes is the largest secret or variable: 48 KB as
19 // docs.github.com writes it, taken as 48 KiB.
20 MaxSettingValueBytes = 48 * 1024
21 // MaxSettingNameLen is the longest name of a secret, variable or
22 // environment, in characters.
23 MaxSettingNameLen = 255
24 // ReservedSettingPrefix starts no secret or variable name: GitHub keeps it
25 // for its own.
26 ReservedSettingPrefix = "GITHUB_"
27)
28
29// ErrSecretsKeyMismatch means the data was sealed with another
30// ERMITE_SECRETS_KEY than the one the server runs with: it refuses to start
31// rather than hand out values it cannot read or seal new ones with a key the
32// old ones do not open with.
33var ErrSecretsKeyMismatch = errors.New("ERMITE_SECRETS_KEY is not the key the stored secrets were sealed with")
34
35// ParseSettingName checks the name of a secret or variable and returns it in
36// upper case, the way it is stored: letters, digits and "_", not starting with
37// a digit or with GITHUB_, 1 to 255 characters, case-insensitive.
38func ParseSettingName(s string) (string, error) {
39 if s == "" || len(s) > MaxSettingNameLen {
40 return "", &TextError{Reason: "must be 1 to 255 characters"}
41 }
42 for i := 0; i < len(s); i++ {
43 c := s[i]
44 switch {
45 case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c == '_':
46 case c >= '0' && c <= '9' && i > 0:
47 default:
48 return "", &TextError{Reason: "may only hold letters, digits and underscores, and must not start with a digit"}
49 }
50 }
51 up := strings.ToUpper(s)
52 if strings.HasPrefix(up, ReservedSettingPrefix) {
53 return "", &TextError{Reason: "must not start with " + ReservedSettingPrefix}
54 }
55 return up, nil
56}
57
58// CheckSecretValue checks the value of a secret: 1 byte to 48 KiB.
59func CheckSecretValue(v string) error {
60 switch {
61 case v == "":
62 return &TextError{Reason: "must not be empty"}
63 case len(v) > MaxSettingValueBytes:
64 return &TextError{Reason: "must be at most 49,152 bytes (48 KiB)"}
65 }
66 return nil
67}
68
69// CheckVariableValue checks the value of a variable: at most 48 KiB, may be
70// empty.
71func CheckVariableValue(v string) error {
72 if len(v) > MaxSettingValueBytes {
73 return &TextError{Reason: "must be at most 49,152 bytes (48 KiB)"}
74 }
75 return nil
76}
77
78// ParseEnvironmentName checks the name of an environment: 1 to 255 characters
79// of valid UTF-8 without control characters or leading and trailing whitespace
80// (the one rule for the API, the CLI and workflows: a name is never trimmed).
81// Names are unique without regard to case; the case given is kept.
82func ParseEnvironmentName(s string) (string, error) {
83 if s == "" || utf8.RuneCountInString(s) > MaxSettingNameLen {
84 return "", &TextError{Reason: "must be 1 to 255 characters"}
85 }
86 if !utf8.ValidString(s) {
87 return "", &TextError{Reason: "must be valid UTF-8"}
88 }
89 first, _ := utf8.DecodeRuneInString(s)
90 last, _ := utf8.DecodeLastRuneInString(s)
91 if unicode.IsSpace(first) || unicode.IsSpace(last) {
92 return "", &TextError{Reason: "must not start or end with whitespace"}
93 }
94 for _, r := range s {
95 if unicode.IsControl(r) || isBidiControl(r) {
96 return "", &TextError{Reason: "must not hold control characters"}
97 }
98 }
99 return s, nil
100}
101
102// SettingsScopeKind tells where secrets and variables live.
103type SettingsScopeKind uint8
104
105// Scope kinds.
106const (
107 // ScopeKindInstance is the whole server.
108 ScopeKindInstance SettingsScopeKind = iota + 1
109 // ScopeKindRepo is one repository.
110 ScopeKindRepo
111 // ScopeKindEnvironment is one environment of a repository.
112 ScopeKindEnvironment
113)
114
115// SettingsScope says where a secret or a variable lives. Build it with
116// InstanceScope, RepoScope or EnvironmentScope; the zero value is invalid.
117type SettingsScope struct {
118 Kind SettingsScopeKind
119 Repo RepoName
120 // Env is the environment name in the case it was given. Scopes compare by
121 // Key, which folds the case.
122 Env string
123}
124
125// InstanceScope is the scope of the whole server.
126func InstanceScope() SettingsScope { return SettingsScope{Kind: ScopeKindInstance} }
127
128// RepoScope is the scope of a repository.
129func RepoScope(repo RepoName) SettingsScope { return SettingsScope{Kind: ScopeKindRepo, Repo: repo} }
130
131// EnvironmentScope is the scope of an environment of a repository; the name
132// must follow ParseEnvironmentName.
133func EnvironmentScope(repo RepoName, env string) SettingsScope {
134 return SettingsScope{Kind: ScopeKindEnvironment, Repo: repo, Env: env}
135}
136
137// Valid reports whether the scope is well formed.
138func (s SettingsScope) Valid() bool {
139 switch s.Kind {
140 case ScopeKindInstance:
141 return true
142 case ScopeKindRepo:
143 return !s.Repo.IsZero()
144 case ScopeKindEnvironment:
145 if s.Repo.IsZero() {
146 return false
147 }
148 _, err := ParseEnvironmentName(s.Env)
149 return err == nil
150 }
151 return false
152}
153
154// Key is the scope as the storage and the encryption name it: "i",
155// "r/<repo>" or "e/<repo>/<environment>". The environment is lower-cased and
156// escaped so that its key holds no "/" and two names that differ by case are
157// one scope.
158func (s SettingsScope) Key() string {
159 switch s.Kind {
160 case ScopeKindInstance:
161 return "i"
162 case ScopeKindRepo:
163 return "r/" + s.Repo.String()
164 case ScopeKindEnvironment:
165 return "e/" + s.Repo.String() + "/" + EnvironmentKey(s.Env)
166 }
167 return ""
168}
169
170// EnvironmentKey is the part of a storage key that names an environment: its
171// name lower-cased and path-escaped.
172func EnvironmentKey(name string) string { return url.PathEscape(strings.ToLower(name)) }
173
174// SecretAAD is the additional data a secret is sealed with. It binds the
175// ciphertext to its scope and name, so a stored value moved to another secret
176// does not open.
177func SecretAAD(scope SettingsScope, name string) []byte {
178 return []byte("ermite-secret\x00" + scope.Key() + "\x00" + name)
179}
180
181// Secret describes a stored secret; it never holds the value.
182type Secret struct {
183 Name string
184 UpdatedAt time.Time
185}
186
187// Variable is a configuration variable.
188type Variable struct {
189 Name string
190 Value string
191 UpdatedAt time.Time
192}
193
194// Environment is a named deployment target of a repository.
195type Environment struct {
196 Repo RepoName
197 Name string
198 CreatedAt time.Time
199}