// 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, `'`, `'\''`) + `'` }