summaryrefslogtreecommitdiff
path: root/internal/envfile/envfile.go
blob: e729ab2c568752d82a226f1d7ef7e66f10d20cb8 (plain)
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
89
90
// Package envfile reads and writes the KEY='value' shell-sourceable env
// file skiff stores at each app's /app/env - the on-disk form of `skiff
// config`. It only ever parses the format Save itself writes, not general
// POSIX shell syntax.
package envfile

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"
)

var keyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

// ValidKey reports whether key is a legal env var name. Save is the
// authoritative guard and re-checks every key itself; callers can use
// ValidKey for an earlier, pre-snapshot failure on a typo'd key.
func ValidKey(key string) bool {
	return keyPattern.MatchString(key)
}

// Load reads path into a KEY->value map. A missing file is not an error -
// it's treated as an empty config, covering a fresh app and list/unset
// against one.
func Load(path string) (map[string]string, error) {
	data, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return map[string]string{}, nil
	}
	if err != nil {
		return nil, err
	}

	kv := map[string]string{}
	for _, line := range strings.Split(string(data), "\n") {
		if line == "" {
			continue
		}
		eq := strings.IndexByte(line, '=')
		if eq < 0 {
			continue
		}
		key := line[:eq]
		quoted := line[eq+1:]
		if !strings.HasPrefix(quoted, "'") || !strings.HasSuffix(quoted, "'") {
			continue
		}
		value := strings.ReplaceAll(quoted[1:len(quoted)-1], `'\''`, `'`)
		kv[key] = value
	}
	return kv, nil
}

// Save validates every key and writes kv to path atomically (write to a
// temp file, then rename over path) at mode 0600 - a deliberate departure
// from the codebase's universal 0644, since this file holds secrets.
func Save(path string, kv map[string]string) error {
	keys := make([]string, 0, len(kv))
	for key := range kv {
		if !keyPattern.MatchString(key) {
			return fmt.Errorf("invalid env var key %q", key)
		}
		keys = append(keys, key)
	}
	sort.Strings(keys)

	var b strings.Builder
	for _, key := range keys {
		b.WriteString(key)
		b.WriteByte('=')
		b.WriteString(escape(kv[key]))
		b.WriteByte('\n')
	}

	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
		return err
	}
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, []byte(b.String()), 0600); err != nil {
		return err
	}
	return os.Rename(tmp, path)
}

func escape(value string) string {
	return `'` + strings.ReplaceAll(value, `'`, `'\''`) + `'`
}