1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package config parses and validates the server configuration from the
4// environment. It is a composition-root helper: only internal/app imports it, and it never
5// reads the process environment itself, the caller passes a lookup function.
6package config
7
8import (
9 "crypto/sha256"
10 "encoding/base64"
11 "fmt"
12 "net"
13 "net/netip"
14 "net/url"
15 "os"
16 "path/filepath"
17 "strconv"
18 "strings"
19 "time"
20 "unicode/utf8"
21
22 "bnema.dev/ermite/internal/mediapolicy"
23)
24
25// Variable names.
26const (
27 EnvVideoHosts = "ERMITE_VIDEO_HOSTS"
28 EnvAddr = "ERMITE_ADDR"
29 EnvDataDir = "ERMITE_DATA_DIR"
30 EnvPublicURL = "ERMITE_PUBLIC_URL"
31 EnvAdminToken = "ERMITE_ADMIN_TOKEN"
32 EnvTrustedProxies = "ERMITE_TRUSTED_PROXIES"
33 EnvGit = "ERMITE_GIT"
34 EnvGitReader = "ERMITE_GIT_READER"
35 EnvSourceURL = "ERMITE_SOURCE_URL"
36 EnvLogLevel = "ERMITE_LOG_LEVEL"
37 EnvLogFormat = "ERMITE_LOG_FORMAT"
38 // EnvCommitterName and EnvCommitterEmail name the committer of the
39 // commits ermite creates (squash merges, ADR 0022).
40 EnvCommitterName = "ERMITE_COMMITTER_NAME"
41 EnvCommitterEmail = "ERMITE_COMMITTER_EMAIL"
42 // EnvOwner is the owner login CI shows in the github context
43 // ("<owner>/<repo>"); it follows the rules of repository names.
44 EnvOwner = "ERMITE_OWNER"
45 // EnvCILogRetention is how long CI runs, jobs, checks and logs are kept.
46 EnvCILogRetention = "ERMITE_CI_LOG_RETENTION"
47 // EnvCIPollTimeout is how long FetchJob holds a runner's call. Lower it
48 // when a reverse proxy cuts idle responses sooner than the default 50 s.
49 EnvCIPollTimeout = "ERMITE_CI_POLL_TIMEOUT"
50 // EnvSecretsKey is the master key that encrypts CI secrets at rest: 32
51 // random bytes in base64, provided by the deployment secret store like
52 // ERMITE_ADMIN_TOKEN. Without it secrets cannot be set and jobs get none.
53 EnvSecretsKey = "ERMITE_SECRETS_KEY"
54 // EnvCIArtifacts turns the CI artifacts (artifacts v4) off when "0" or
55 // "false"; they are on by default.
56 EnvCIArtifacts = "ERMITE_CI_ARTIFACTS"
57 // EnvCIArtifactRetention is the default and longest retention of an
58 // artifact, EnvCIArtifactQuota the space of the artifacts of one
59 // repository, EnvCIArtifactMax the size of one artifact.
60 EnvCIArtifactRetention = "ERMITE_CI_ARTIFACT_RETENTION"
61 EnvCIArtifactQuota = "ERMITE_CI_ARTIFACT_QUOTA"
62 EnvCIArtifactMax = "ERMITE_CI_ARTIFACT_MAX"
63 EnvS3Bucket = "ERMITE_S3_BUCKET"
64 EnvS3Endpoint = "ERMITE_S3_ENDPOINT"
65 EnvS3Region = "ERMITE_S3_REGION"
66 EnvS3AccessKeyID = "ERMITE_S3_ACCESS_KEY_ID"
67 EnvS3SecretAccessKey = "ERMITE_S3_SECRET_ACCESS_KEY"
68 EnvS3ForcePathStyle = "ERMITE_S3_FORCE_PATH_STYLE"
69 EnvS3Prefix = "ERMITE_S3_PREFIX"
70 EnvReleaseAssetMax = "ERMITE_RELEASE_ASSET_MAX"
71 EnvReleaseAssetsMax = "ERMITE_RELEASE_ASSETS_MAX"
72 EnvReleaseQuota = "ERMITE_RELEASE_QUOTA"
73)
74
75// SecretsKeyLen is the length of the secrets key in bytes (AES-256).
76const SecretsKeyLen = 32
77
78// Defaults.
79const (
80 DefaultAddr = ":7341"
81 DefaultGit = "git"
82 DefaultGitReader = GitReaderErmiteGit
83 DefaultLogLevel = "info"
84 DefaultLogFormat = "json"
85 // DefaultCommitterName is the committer name of the commits ermite creates.
86 DefaultCommitterName = "ermite"
87 // DefaultOwner is the owner login when ERMITE_OWNER is not set.
88 DefaultOwner = "owner"
89)
90
91// Values of ERMITE_GIT_READER.
92const (
93 // GitReaderCLI reads repositories with the git binary only.
94 GitReaderCLI = "cli"
95 // GitReaderErmiteGit reads them with the ermite-git library where it is
96 // proven faster, and with the git binary elsewhere.
97 GitReaderErmiteGit = "ermite-git"
98)
99
100// Bounds of ERMITE_CI_POLL_TIMEOUT. The upper bound keeps the poll under the 60 s
101// that the runner waits for one FetchJob; the lower one stops runners from
102// polling in a tight loop.
103const (
104 MinCIPollTimeout = time.Second
105 MaxCIPollTimeout = 55 * time.Second
106)
107
108// MinAdminTokenLen is the shortest accepted admin token, in characters.
109const MinAdminTokenLen = 32
110
111// Config is the validated server configuration.
112type Config struct {
113 // VideoHosts are exact external hosts allowed to load README videos.
114 VideoHosts []string
115 // Addr is the listen address, "host:port".
116 Addr string
117 // DataDir is the absolute, clean data root.
118 DataDir string
119 // PublicURL is the base URL without a trailing slash.
120 PublicURL string
121 // AdminTokenHash is the SHA-256 of the admin token. The token itself is
122 // dropped; compare with a constant-time function.
123 AdminTokenHash [sha256.Size]byte
124 // TrustedProxies are the networks allowed to set X-Forwarded-For.
125 TrustedProxies []netip.Prefix
126 // Git is the git executable, a name or a path.
127 Git string
128 // GitReader is GitReaderCLI or GitReaderErmiteGit.
129 GitReader string
130 // SourceURL is the link to the source of the running version.
131 SourceURL string
132 // LogLevel is one of trace, debug, info, warn, error.
133 LogLevel string
134 // LogFormat is json or console.
135 LogFormat string
136 // CommitterName and CommitterEmail are recorded as the committer of the
137 // commits ermite creates. The email defaults to "ermite@<host of
138 // PublicURL>".
139 CommitterName, CommitterEmail string
140 // Owner is the owner login of the github context of CI runs.
141 Owner string
142 // CIRetention is how long CI data is kept; 0 (variable unset) leaves the
143 // default to the CI use case.
144 CIRetention time.Duration
145 // CIPollTimeout is how long FetchJob holds a call; 0 (variable unset)
146 // leaves the default to the CI use case.
147 CIPollTimeout time.Duration
148 // SecretsKey is the decoded ERMITE_SECRETS_KEY (SecretsKeyLen bytes); nil
149 // when the variable is unset. It is a secret: never log it or put it in an
150 // error.
151 SecretsKey []byte
152 // CIArtifacts is false when ERMITE_CI_ARTIFACTS turns the artifacts off.
153 CIArtifacts bool
154 // CIArtifactRetention, CIArtifactQuota and CIArtifactMax are the limits of
155 // the artifacts; 0 (variable unset) leaves the default to the CI use case.
156 CIArtifactRetention time.Duration
157 CIArtifactQuota int64
158 CIArtifactMax int64
159 // S3Bucket selects S3; empty selects local disk. Credentials are secrets.
160 S3Bucket, S3Endpoint, S3Region, S3AccessKeyID, S3SecretAccessKey, S3Prefix string
161 S3ForcePathStyle bool
162 // Zero release limits leave defaults to the release use case.
163 ReleaseAssetMax, ReleaseQuota int64
164 ReleaseAssetsMax int
165}
166
167// Error lists every configuration problem found, in variable order.
168type Error struct {
169 Problems []string
170}
171
172// Error implements error.
173func (e *Error) Error() string {
174 var b strings.Builder
175 b.WriteString("invalid configuration:")
176 for _, p := range e.Problems {
177 b.WriteString("\n - ")
178 b.WriteString(p)
179 }
180 return b.String()
181}
182
183// Load reads the configuration through lookup, which returns the value of a
184// variable, or "" when it is unset (os.Getenv fits). An empty value counts as
185// unset. All problems are reported together in an *Error. Messages name the
186// variable and never contain the admin token.
187func Load(get func(string) string) (Config, error) {
188 var problems []string
189 bad := func(key, format string, args ...any) {
190 problems = append(problems, key+": "+fmt.Sprintf(format, args...))
191 }
192 get = resolveFiles(get, &problems)
193 var c Config
194 var hostErr error
195 c.VideoHosts, hostErr = mediapolicy.Parse(get(EnvVideoHosts))
196 if hostErr != nil {
197 bad(EnvVideoHosts, "%v", hostErr)
198 }
199
200 c.Addr = get(EnvAddr)
201 if c.Addr == "" {
202 c.Addr = DefaultAddr
203 } else if err := validateAddr(c.Addr); err != nil {
204 bad(EnvAddr, "%v", err)
205 }
206
207 switch dir := get(EnvDataDir); {
208 case dir == "":
209 bad(EnvDataDir, "required")
210 case !filepath.IsAbs(dir):
211 bad(EnvDataDir, "must be an absolute path")
212 default:
213 c.DataDir = filepath.Clean(dir)
214 }
215
216 publicOK := false
217 if raw := get(EnvPublicURL); raw == "" {
218 bad(EnvPublicURL, "required")
219 } else if u, err := parseURL(raw, false); err != nil {
220 bad(EnvPublicURL, "%v", err)
221 } else {
222 c.PublicURL = u
223 publicOK = true
224 }
225
226 if token := get(EnvAdminToken); token == "" {
227 bad(EnvAdminToken, "required")
228 } else if n := utf8.RuneCountInString(token); n < MinAdminTokenLen {
229 bad(EnvAdminToken, "must be at least %d characters (got %d)", MinAdminTokenLen, n)
230 } else {
231 c.AdminTokenHash = sha256.Sum256([]byte(token))
232 }
233
234 if raw := get(EnvTrustedProxies); raw != "" {
235 prefixes, err := parseCIDRs(raw)
236 if err != nil {
237 bad(EnvTrustedProxies, "%v", err)
238 }
239 c.TrustedProxies = prefixes
240 }
241
242 c.Git = get(EnvGit)
243 if c.Git == "" {
244 c.Git = DefaultGit
245 }
246
247 c.GitReader = strings.ToLower(strings.TrimSpace(get(EnvGitReader)))
248 switch c.GitReader {
249 case "":
250 c.GitReader = DefaultGitReader
251 case GitReaderCLI, GitReaderErmiteGit:
252 default:
253 bad(EnvGitReader, "must be %s or %s (got %q)", GitReaderCLI, GitReaderErmiteGit, get(EnvGitReader))
254 }
255
256 switch raw := get(EnvSourceURL); {
257 case raw != "":
258 if u, err := parseURL(raw, true); err != nil {
259 bad(EnvSourceURL, "%v", err)
260 } else {
261 c.SourceURL = u
262 }
263 case publicOK:
264 c.SourceURL = c.PublicURL + "/ermite"
265 }
266
267 c.LogLevel = strings.ToLower(get(EnvLogLevel))
268 switch c.LogLevel {
269 case "":
270 c.LogLevel = DefaultLogLevel
271 case "trace", "debug", "info", "warn", "error":
272 default:
273 bad(EnvLogLevel, "must be one of trace, debug, info, warn, error (got %q)", get(EnvLogLevel))
274 }
275
276 c.LogFormat = strings.ToLower(get(EnvLogFormat))
277 switch c.LogFormat {
278 case "":
279 c.LogFormat = DefaultLogFormat
280 case "json", "console":
281 default:
282 bad(EnvLogFormat, "must be json or console (got %q)", get(EnvLogFormat))
283 }
284
285 c.CommitterName = strings.TrimSpace(get(EnvCommitterName))
286 if c.CommitterName == "" {
287 c.CommitterName = DefaultCommitterName
288 } else if err := validateIdentity(c.CommitterName); err != nil {
289 bad(EnvCommitterName, "%v", err)
290 }
291 c.CommitterEmail = strings.TrimSpace(get(EnvCommitterEmail))
292 switch {
293 case c.CommitterEmail != "":
294 if err := validateIdentity(c.CommitterEmail); err != nil {
295 bad(EnvCommitterEmail, "%v", err)
296 } else if !strings.Contains(c.CommitterEmail, "@") || strings.ContainsAny(c.CommitterEmail, " \t") {
297 bad(EnvCommitterEmail, "must be an email address such as ermite@example.org")
298 }
299 case publicOK:
300 u, _ := url.Parse(c.PublicURL)
301 c.CommitterEmail = "ermite@" + u.Hostname()
302 }
303
304 // The name rules of the owner are those of repository names (the domain);
305 // config imports nothing, so internal/app checks them (see loadConfig).
306 c.Owner = strings.TrimSpace(get(EnvOwner))
307 if c.Owner == "" {
308 c.Owner = DefaultOwner
309 }
310
311 if raw := strings.TrimSpace(get(EnvCILogRetention)); raw != "" {
312 d, err := parseRetention(raw)
313 if err != nil {
314 bad(EnvCILogRetention, "%v", err)
315 } else {
316 c.CIRetention = d
317 }
318 }
319
320 if raw := strings.TrimSpace(get(EnvCIPollTimeout)); raw != "" {
321 d, err := time.ParseDuration(raw)
322 switch {
323 case err != nil:
324 bad(EnvCIPollTimeout, "must be a duration such as 25s (got %q)", raw)
325 case d < MinCIPollTimeout || d > MaxCIPollTimeout:
326 bad(EnvCIPollTimeout, "must be between %v and %v (got %q)", MinCIPollTimeout, MaxCIPollTimeout, raw)
327 default:
328 c.CIPollTimeout = d
329 }
330 }
331
332 if raw := strings.TrimSpace(get(EnvSecretsKey)); raw != "" {
333 // The message never repeats the value.
334 if key, ok := decodeSecretsKey(raw); !ok {
335 bad(EnvSecretsKey, "must be %d random bytes in base64 (for example `head -c 32 /dev/urandom | base64`)", SecretsKeyLen)
336 } else {
337 c.SecretsKey = key
338 }
339 }
340
341 switch raw := strings.ToLower(strings.TrimSpace(get(EnvCIArtifacts))); raw {
342 case "", "1", "true":
343 c.CIArtifacts = true
344 case "0", "false":
345 default:
346 bad(EnvCIArtifacts, "must be 0, 1, false or true (got %q)", raw)
347 }
348 if raw := strings.TrimSpace(get(EnvCIArtifactRetention)); raw != "" {
349 d, err := parseRetention(raw)
350 switch {
351 case err != nil:
352 bad(EnvCIArtifactRetention, "%v", err)
353 case d < 24*time.Hour:
354 bad(EnvCIArtifactRetention, "must be at least one day (got %q)", raw)
355 default:
356 c.CIArtifactRetention = d
357 }
358 }
359 for _, v := range []struct {
360 env string
361 dst *int64
362 }{{EnvCIArtifactQuota, &c.CIArtifactQuota}, {EnvCIArtifactMax, &c.CIArtifactMax}} {
363 if raw := strings.TrimSpace(get(v.env)); raw != "" {
364 n, err := parseSize(raw)
365 if err != nil {
366 bad(v.env, "%v", err)
367 } else {
368 *v.dst = n
369 }
370 }
371 }
372
373 loadStorage(get, &c, &problems)
374 if len(problems) > 0 {
375 return Config{}, &Error{Problems: problems}
376 }
377 return c, nil
378}
379
380// resolveFiles resolves only the four supported secrets, once at startup.
381// Neither file paths, file errors nor secret contents enter diagnostics.
382func resolveFiles(get func(string) string, problems *[]string) func(string) string {
383 values := make(map[string]string)
384 for _, key := range []string{EnvAdminToken, EnvSecretsKey, EnvS3AccessKeyID, EnvS3SecretAccessKey} {
385 value, file := get(key), get(key+"_FILE")
386 if value != "" && file != "" {
387 *problems = append(*problems, key+": cannot set both variable and _FILE")
388 } else if file != "" {
389 data, err := os.ReadFile(file)
390 if err != nil {
391 *problems = append(*problems, key+"_FILE: cannot read secret file")
392 } else {
393 value = strings.TrimRight(string(data), "\r\n")
394 if value == "" {
395 *problems = append(*problems, key+"_FILE: secret file is empty")
396 }
397 }
398 }
399 values[key] = value
400 }
401 return func(key string) string {
402 if value, ok := values[key]; ok {
403 return value
404 }
405 return get(key)
406 }
407}
408
409func loadStorage(get func(string) string, c *Config, problems *[]string) {
410 bad := func(key, message string) { *problems = append(*problems, key+": "+message) }
411 c.S3Bucket = get(EnvS3Bucket)
412 c.S3Endpoint = get(EnvS3Endpoint)
413 c.S3Region = get(EnvS3Region)
414 c.S3AccessKeyID = get(EnvS3AccessKeyID)
415 c.S3SecretAccessKey = get(EnvS3SecretAccessKey)
416 c.S3Prefix = strings.Trim(get(EnvS3Prefix), "/")
417 if c.S3Prefix != "" {
418 valid := len(c.S3Prefix) <= 512
419 for _, part := range strings.Split(c.S3Prefix, "/") {
420 if part == "" || part == "." || part == ".." {
421 valid = false
422 }
423 }
424 for _, r := range c.S3Prefix {
425 if r < 0x21 || r > 0x7e || r == '\\' {
426 valid = false
427 }
428 }
429 if !valid {
430 bad(EnvS3Prefix, "must be an ASCII key prefix without empty, dot or dot-dot segments")
431 }
432 c.S3Prefix += "/"
433 }
434 if c.S3Endpoint != "" {
435 if endpoint, err := parseURL(c.S3Endpoint, false); err != nil {
436 bad(EnvS3Endpoint, "must be an http(s) URL without credentials, path, query or fragment")
437 } else {
438 c.S3Endpoint = endpoint
439 }
440 }
441 switch get(EnvS3ForcePathStyle) {
442 case "", "0", "false":
443 case "1", "true":
444 c.S3ForcePathStyle = true
445 default:
446 bad(EnvS3ForcePathStyle, "must be 0, 1, false or true")
447 }
448 if c.S3Bucket != "" {
449 for _, v := range []struct{ key, value string }{{EnvS3Region, c.S3Region}, {EnvS3AccessKeyID, c.S3AccessKeyID}, {EnvS3SecretAccessKey, c.S3SecretAccessKey}} {
450 if strings.TrimSpace(v.value) == "" {
451 bad(v.key, "required when ERMITE_S3_BUCKET is set")
452 }
453 }
454 }
455 for _, v := range []struct {
456 key string
457 dst *int64
458 }{{EnvReleaseAssetMax, &c.ReleaseAssetMax}, {EnvReleaseQuota, &c.ReleaseQuota}} {
459 if raw := strings.TrimSpace(get(v.key)); raw != "" {
460 n, err := parseSize(raw)
461 if err != nil {
462 bad(v.key, err.Error())
463 } else {
464 *v.dst = n
465 }
466 }
467 }
468 if raw := get(EnvReleaseAssetsMax); raw != "" {
469 n, err := strconv.Atoi(raw)
470 if err != nil || n < 1 {
471 bad(EnvReleaseAssetsMax, "must be a positive integer")
472 } else {
473 c.ReleaseAssetsMax = n
474 }
475 }
476}
477
478// sizeUnits are the suffixes of parseSize, longest first.
479var sizeUnits = []struct {
480 suffix string
481 mult int64
482}{{"TiB", 1 << 40}, {"GiB", 1 << 30}, {"MiB", 1 << 20}, {"KiB", 1 << 10}, {"B", 1}}
483
484// parseSize reads a size in bytes: a number with an optional suffix B, KiB,
485// MiB, GiB or TiB ("10GiB", "512 MiB"), from 1 MiB to 1 PiB.
486func parseSize(raw string) (int64, error) {
487 num, mult := raw, int64(1)
488 for _, u := range sizeUnits {
489 if n, ok := strings.CutSuffix(raw, u.suffix); ok {
490 num, mult = strings.TrimSpace(n), u.mult
491 break
492 }
493 }
494 n, err := strconv.ParseInt(num, 10, 64)
495 if err != nil || n < 1 || n > (1<<50)/mult || n*mult < 1<<20 {
496 return 0, fmt.Errorf("must be a size from 1MiB to 1024TiB such as 10GiB (got %q)", raw)
497 }
498 return n * mult, nil
499}
500
501// decodeSecretsKey reads 32 bytes in standard or URL-safe base64, with or
502// without padding.
503func decodeSecretsKey(raw string) ([]byte, bool) {
504 for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
505 if key, err := enc.DecodeString(raw); err == nil {
506 return key, len(key) == SecretsKeyLen
507 }
508 }
509 return nil, false
510}
511
512// parseRetention reads a duration in days ("90d") or in Go's syntax ("720h"),
513// at least one hour.
514func parseRetention(raw string) (time.Duration, error) {
515 var d time.Duration
516 if days, ok := strings.CutSuffix(raw, "d"); ok {
517 n, err := strconv.Atoi(days)
518 if err != nil || n < 1 || n > 36500 {
519 return 0, fmt.Errorf("must be a number of days such as 90d (got %q)", raw)
520 }
521 d = time.Duration(n) * 24 * time.Hour
522 } else {
523 var err error
524 if d, err = time.ParseDuration(raw); err != nil {
525 return 0, fmt.Errorf("must be a duration such as 90d or 720h (got %q)", raw)
526 }
527 }
528 if d < time.Hour {
529 return 0, fmt.Errorf("must be at least one hour (got %q)", raw)
530 }
531 return d, nil
532}
533
534// validateIdentity accepts a name or an email address for a Git identity: valid
535// UTF-8 without control characters or the angle brackets and line breaks that
536// Git does not allow in an identity.
537func validateIdentity(s string) error {
538 if !utf8.ValidString(s) {
539 return fmt.Errorf("must be valid UTF-8")
540 }
541 for _, r := range s {
542 if r < 0x20 || r == 0x7f || r == '<' || r == '>' {
543 return fmt.Errorf("must not contain control characters, '<' or '>'")
544 }
545 }
546 return nil
547}
548
549func validateAddr(addr string) error {
550 _, port, err := net.SplitHostPort(addr)
551 if err != nil {
552 return fmt.Errorf("must be host:port, such as %q (%v)", DefaultAddr, err)
553 }
554 n, err := strconv.Atoi(port)
555 if err != nil || n < 1 || n > 65535 {
556 return fmt.Errorf("port must be a number from 1 to 65535 (got %q)", port)
557 }
558 return nil
559}
560
561// parseURL accepts an http(s) URL with a host and no credentials, query or
562// fragment, and returns it without a trailing slash. A path is accepted only
563// with allowPath.
564func parseURL(raw string, allowPath bool) (string, error) {
565 u, err := url.Parse(raw)
566 if err != nil {
567 return "", fmt.Errorf("not a valid URL: %v", err)
568 }
569 switch {
570 case u.Scheme != "http" && u.Scheme != "https":
571 return "", fmt.Errorf("scheme must be http or https (got %q)", u.Scheme)
572 case u.Host == "" || u.Hostname() == "":
573 return "", fmt.Errorf("host is missing")
574 case u.User != nil:
575 return "", fmt.Errorf("must not contain credentials")
576 case !allowPath && u.Path != "" && u.Path != "/":
577 return "", fmt.Errorf("must not contain a path (got %q)", u.Path)
578 case u.RawQuery != "" || u.ForceQuery:
579 return "", fmt.Errorf("must not contain a query")
580 case u.Fragment != "" || strings.Contains(raw, "#"):
581 return "", fmt.Errorf("must not contain a fragment")
582 }
583 return strings.TrimSuffix(u.String(), "/"), nil
584}
585
586func parseCIDRs(raw string) ([]netip.Prefix, error) {
587 var out []netip.Prefix
588 for part := range strings.SplitSeq(raw, ",") {
589 part = strings.TrimSpace(part)
590 if part == "" {
591 return nil, fmt.Errorf("empty entry in the comma-separated list")
592 }
593 p, err := netip.ParsePrefix(part)
594 if err != nil {
595 return nil, fmt.Errorf("%q is not a CIDR such as 10.0.0.0/8 or fd00::/8", part)
596 }
597 out = append(out, p.Masked())
598 }
599 return out, nil
600}