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
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
package main

import (
	"database/sql"
	"fmt"
	"os"
	"strings"
)

// =============================================================================
// _hosty_config helpers
// =============================================================================

type configEntry struct {
	key        string
	desc       string
	required   bool
	sensitive  bool
	defaultVal *string
}

// splitConfigSpec splits a config spec string on ':' treating '::' as a
// literal ':'. Returns the unescaped fields.
func splitConfigSpec(s string) []string {
	var fields []string
	var cur strings.Builder
	for i := 0; i < len(s); i++ {
		if s[i] == ':' && i+1 < len(s) && s[i+1] == ':' {
			cur.WriteByte(':')
			i++ // skip second ':'
		} else if s[i] == ':' {
			fields = append(fields, cur.String())
			cur.Reset()
		} else {
			cur.WriteByte(s[i])
		}
	}
	fields = append(fields, cur.String())
	return fields
}

// parseConfigEntry parses a -config flag value of the form:
//
//	KEY:description (may contain :: escaped colons):modifier1,modifier2,...
//
// Modifiers: required (default), optional, sensitive, default=VALUE
func parseConfigEntry(s string) (configEntry, error) {
	fields := splitConfigSpec(s)
	if len(fields) < 2 {
		return configEntry{}, fmt.Errorf("config spec %q: must have at least KEY:modifiers", s)
	}

	e := configEntry{
		key:      fields[0],
		required: true, // default
	}
	if e.key == "" {
		return configEntry{}, fmt.Errorf("config spec %q: key may not be empty", s)
	}

	// Middle fields (between key and last field) are the description.
	modifiersField := fields[len(fields)-1]
	if len(fields) > 2 {
		e.desc = strings.Join(fields[1:len(fields)-1], ":")
	}
	// If only 2 fields and last field looks like modifiers (no spaces), treat as modifiers only.
	// If it looks like a description (has spaces or no known modifier keywords), treat as description
	// with empty modifiers.
	if len(fields) == 2 {
		// Heuristic: if second field contains only modifier keywords separated by commas, it's modifiers.
		// Otherwise treat as description with no modifiers.
		looksLikeModifiers := true
		for mod := range strings.SplitSeq(modifiersField, ",") {
			mod = strings.TrimSpace(mod)
			if mod != "required" && mod != "optional" && mod != "sensitive" && !strings.HasPrefix(mod, "default=") {
				looksLikeModifiers = false
				break
			}
		}
		if !looksLikeModifiers {
			e.desc = modifiersField
			modifiersField = ""
		}
	}

	// Parse modifiers.
	for mod := range strings.SplitSeq(modifiersField, ",") {
		mod = strings.TrimSpace(mod)
		switch {
		case mod == "" || mod == "required":
			// required is the default, nothing to do
		case mod == "optional":
			e.required = false
		case mod == "sensitive":
			e.sensitive = true
		case strings.HasPrefix(mod, "default="):
			val := mod[len("default="):]
			e.defaultVal = &val
			e.required = false // having a default implies optional
		default:
			return configEntry{}, fmt.Errorf("config spec %q: unknown modifier %q", s, mod)
		}
	}

	return e, nil
}

// stripSensitiveFromData moves any sensitive values that arrived in data.hosty
// (e.g. from an exported .hosty with -include-secrets) into the environment
// database, then NULLs them in data.hosty.
// Write order: environment first, then NULL in data — so a crash between the
// two leaves the value in both places (harmless; we re-strip on next start).
func stripSensitiveFromData(dataDB, envDB *sql.DB, app string) error {
	rows, err := dataDB.Query(`
		SELECT key, value FROM _hosty_config
		WHERE sensitive = 1 AND value IS NOT NULL
	`)
	if err != nil {
		return nil // table may not have value column yet
	}
	defer rows.Close()

	var keys []string
	vals := map[string]string{}
	for rows.Next() {
		var k, v string
		if err := rows.Scan(&k, &v); err != nil {
			return err
		}
		keys = append(keys, k)
		vals[k] = v
	}
	if err := rows.Err(); err != nil {
		return err
	}
	rows.Close()

	for _, k := range keys {
		// 1. Write to the environment database (durable first).
		if err := secretSet(envDB, app, k, vals[k]); err != nil {
			return fmt.Errorf("writing secret %q: %w", k, err)
		}
		// 2. NULL in data.hosty.
		if _, err := dataDB.Exec(`UPDATE _hosty_config SET value = NULL WHERE key = ?`, k); err != nil {
			return fmt.Errorf("nulling %q in data.hosty: %w", k, err)
		}
		fmt.Fprintf(os.Stderr, "hosty: moved sensitive config %q from data.hosty → %s\n", k, envDBName)
	}
	return nil
}

// missingRequiredConfig returns two lists of required config entries that have
// no value and no default:
//   - hard: non-sensitive; hosty start will refuse to proceed
//   - soft: sensitive; hosty start will warn but continue (user may inject externally)
func missingRequiredConfig(dataDB, envDB *sql.DB, app string) (hard, soft []configEntry, err error) {
	rows, err := dataDB.Query(`
		SELECT key, description, sensitive, default_val, value
		FROM _hosty_config
		WHERE required = 1
		ORDER BY key
	`)
	if err != nil {
		// Table may not exist for apps packed before this feature.
		return nil, nil, nil
	}
	defer rows.Close()

	for rows.Next() {
		var e configEntry
		var defaultVal, value sql.NullString
		var sens int
		e.required = true
		if err := rows.Scan(&e.key, &e.desc, &sens, &defaultVal, &value); err != nil {
			return nil, nil, err
		}
		e.sensitive = sens == 1

		if defaultVal.Valid {
			continue // has a default — not missing
		}
		if !e.sensitive && value.Valid {
			continue // non-sensitive value set in data.hosty
		}
		if e.sensitive {
			if _, err := secretGet(envDB, app, e.key); err == nil {
				continue // sensitive value set in the environment database
			}
			soft = append(soft, e)
		} else {
			hard = append(hard, e)
		}
	}
	return hard, soft, rows.Err()
}