Skip to content

Commit 81f6b4a

Browse files
author
champion
committed
feat: harden env schema validation
1 parent 5da94e6 commit 81f6b4a

File tree

1 file changed

+218
-20
lines changed

1 file changed

+218
-20
lines changed
Lines changed: 218 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
#!/bin/bash
22
# Validate .env against .env.schema.json
3+
#
4+
# Senior-grade validation goals:
5+
# - Correctly parse .env files including quotes and "export KEY=..." lines
6+
# - Report line numbers and actionable messages
7+
# - Validate required keys, unknown keys, types, enums, and numeric ranges
8+
# - Fail deterministically with a single exit code for CI
39

410
set -euo pipefail
511

@@ -12,112 +18,304 @@ RED='\033[0;31m'
1218
GREEN='\033[0;32m'
1319
YELLOW='\033[1;33m'
1420
BLUE='\033[0;34m'
21+
CYAN='\033[0;36m'
22+
BOLD='\033[1m'
1523
NC='\033[0m'
1624

1725
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
1826
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
1927
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
2028
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
2129

30+
usage() {
31+
cat <<EOF
32+
Usage: $(basename "$0") [ENV_FILE] [SCHEMA_FILE]
33+
34+
Validates a Dream Server .env file against the JSON schema.
35+
36+
Exit codes:
37+
0 valid
38+
2 validation errors
39+
3 missing deps / unreadable input
40+
41+
Tips:
42+
- Use .env.example as a reference
43+
- Quote values containing spaces/special characters
44+
EOF
45+
}
46+
47+
for arg in "$@"; do
48+
case "$arg" in
49+
--help|-h) usage; exit 0 ;;
50+
esac
51+
done
52+
2253
if [[ ! -f "$ENV_FILE" ]]; then
2354
log_error "Env file not found: $ENV_FILE"
24-
exit 1
55+
exit 3
2556
fi
2657

2758
if [[ ! -f "$SCHEMA_FILE" ]]; then
2859
log_error "Schema file not found: $SCHEMA_FILE"
29-
exit 1
60+
exit 3
3061
fi
3162

3263
if ! command -v jq >/dev/null 2>&1; then
33-
log_error "jq is required for schema validation (sudo apt install jq)"
34-
exit 1
64+
log_error "jq is required for schema validation"
65+
log_info "Install: sudo apt-get install -y jq (or your distro equivalent)"
66+
exit 3
3567
fi
3668

69+
# -----------------------------
70+
# .env parsing (robust)
71+
# -----------------------------
72+
# We intentionally do NOT 'source' the .env for security reasons.
73+
# Instead we parse key/value pairs ourselves.
74+
3775
declare -A ENV_MAP
38-
while IFS= read -r line; do
39-
[[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue
40-
if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
41-
key="${BASH_REMATCH[1]}"
42-
value="${BASH_REMATCH[2]}"
43-
ENV_MAP["$key"]="$value"
76+
declare -A ENV_LINE
77+
78+
trim() {
79+
local s="$1"
80+
s="${s#${s%%[![:space:]]*}}"
81+
s="${s%${s##*[![:space:]]}}"
82+
printf '%s' "$s"
83+
}
84+
85+
unquote() {
86+
# Remove matching single or double quotes; keep inner content as-is.
87+
local s="$1"
88+
if [[ ${#s} -ge 2 ]]; then
89+
if [[ "$s" == "\""*"\"" ]]; then
90+
printf '%s' "${s:1:${#s}-2}"
91+
return 0
4492
fi
93+
if [[ "$s" == "'"*"'" ]]; then
94+
printf '%s' "${s:1:${#s}-2}"
95+
return 0
96+
fi
97+
fi
98+
printf '%s' "$s"
99+
}
100+
101+
# Split KEY=VALUE where VALUE may contain '='
102+
split_kv() {
103+
local line="$1"
104+
local key="${line%%=*}"
105+
local value="${line#*=}"
106+
key="$(trim "$key")"
107+
value="$(trim "$value")"
108+
printf '%s\n' "$key" "$value"
109+
}
110+
111+
line_no=0
112+
while IFS= read -r raw_line || [[ -n "$raw_line" ]]; do
113+
line_no=$((line_no + 1))
114+
115+
# Strip leading/trailing whitespace
116+
line="$(trim "$raw_line")"
117+
118+
# Skip blanks/comments
119+
[[ -z "$line" ]] && continue
120+
[[ "$line" =~ ^# ]] && continue
121+
122+
# Allow: export KEY=VALUE
123+
if [[ "$line" =~ ^export[[:space:]]+ ]]; then
124+
line="$(trim "${line#export}")"
125+
fi
126+
127+
# Must contain '='
128+
if [[ "$line" != *"="* ]]; then
129+
log_warn "Ignoring line $line_no (not KEY=VALUE): $raw_line"
130+
continue
131+
fi
132+
133+
read -r key value < <(split_kv "$line")
134+
135+
if [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
136+
log_warn "Ignoring line $line_no (invalid key '$key')"
137+
continue
138+
fi
139+
140+
# Remove inline comments only when value is unquoted.
141+
# Example: FOO=bar # comment
142+
# Keep hashes inside quotes.
143+
if [[ "$value" != "\""* && "$value" != "'"* ]]; then
144+
value="$(trim "${value%%#*}")"
145+
fi
146+
147+
value="$(trim "$value")"
148+
value="$(unquote "$value")"
149+
150+
ENV_MAP["$key"]="$value"
151+
ENV_LINE["$key"]="$line_no"
45152
done < "$ENV_FILE"
46153

154+
# -----------------------------
155+
# Schema prep
156+
# -----------------------------
157+
47158
missing=()
48159
unknown=()
49160
type_errors=()
161+
enum_errors=()
162+
range_errors=()
50163

51164
mapfile -t required_keys < <(jq -r '.required[]?' "$SCHEMA_FILE")
165+
166+
mapfile -t schema_keys < <(jq -r '.properties | keys[]' "$SCHEMA_FILE")
167+
declare -A SCHEMA_KEY_SET
168+
for key in "${schema_keys[@]}"; do
169+
SCHEMA_KEY_SET["$key"]=1
170+
done
171+
172+
# -----------------------------
173+
# Required keys
174+
# -----------------------------
175+
52176
for key in "${required_keys[@]}"; do
53177
val="${ENV_MAP[$key]-}"
54178
if [[ -z "$val" ]]; then
55179
missing+=("$key")
56180
fi
57181
done
58182

59-
mapfile -t schema_keys < <(jq -r '.properties | keys[]' "$SCHEMA_FILE")
60-
declare -A SCHEMA_KEY_SET
61-
for key in "${schema_keys[@]}"; do
62-
SCHEMA_KEY_SET["$key"]=1
63-
done
183+
# -----------------------------
184+
# Unknown keys
185+
# -----------------------------
64186

65187
for key in "${!ENV_MAP[@]}"; do
66188
if [[ -z "${SCHEMA_KEY_SET[$key]-}" ]]; then
67189
unknown+=("$key")
68190
fi
69191
done
70192

193+
# -----------------------------
194+
# Type + enum + range checks
195+
# -----------------------------
196+
71197
for key in "${schema_keys[@]}"; do
72198
val="${ENV_MAP[$key]-}"
73199
[[ -z "$val" ]] && continue
74200

75201
expected_type="$(jq -r --arg k "$key" '.properties[$k].type // "string"' "$SCHEMA_FILE")"
202+
203+
# Type validation
76204
case "$expected_type" in
77205
integer)
78206
if [[ ! "$val" =~ ^-?[0-9]+$ ]]; then
79-
type_errors+=("$key (expected integer, got '$val')")
207+
type_errors+=("$key: expected integer, got '$val' (line ${ENV_LINE[$key]:-?})")
208+
continue
80209
fi
81210
;;
82211
number)
83212
if [[ ! "$val" =~ ^-?[0-9]+([.][0-9]+)?$ ]]; then
84-
type_errors+=("$key (expected number, got '$val')")
213+
type_errors+=("$key: expected number, got '$val' (line ${ENV_LINE[$key]:-?})")
214+
continue
85215
fi
86216
;;
87217
boolean)
88218
if [[ "$val" != "true" && "$val" != "false" ]]; then
89-
type_errors+=("$key (expected boolean true/false, got '$val')")
219+
type_errors+=("$key: expected boolean true/false, got '$val' (line ${ENV_LINE[$key]:-?})")
220+
continue
90221
fi
91222
;;
92223
esac
224+
225+
# Enum validation
226+
if jq -e --arg k "$key" '.properties[$k].enum? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
227+
if [[ "$expected_type" != "string" ]]; then
228+
: # enums in our schema are for strings; ignore otherwise
229+
else
230+
if ! jq -e --arg k "$key" --arg v "$val" '.properties[$k].enum | index($v) != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
231+
allowed="$(jq -r --arg k "$key" '.properties[$k].enum | join(", ")' "$SCHEMA_FILE")"
232+
enum_errors+=("$key: invalid value '$val' (allowed: $allowed) (line ${ENV_LINE[$key]:-?})")
233+
fi
234+
fi
235+
fi
236+
237+
# Range validation (minimum/maximum) for numbers/integers
238+
if [[ "$expected_type" == "integer" || "$expected_type" == "number" ]]; then
239+
if jq -e --arg k "$key" '.properties[$k].minimum? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
240+
minv="$(jq -r --arg k "$key" '.properties[$k].minimum' "$SCHEMA_FILE")"
241+
if awk "BEGIN{exit !($val < $minv)}" 2>/dev/null; then
242+
range_errors+=("$key: value $val is < minimum $minv (line ${ENV_LINE[$key]:-?})")
243+
fi
244+
fi
245+
if jq -e --arg k "$key" '.properties[$k].maximum? != null' "$SCHEMA_FILE" >/dev/null 2>&1; then
246+
maxv="$(jq -r --arg k "$key" '.properties[$k].maximum' "$SCHEMA_FILE")"
247+
if awk "BEGIN{exit !($val > $maxv)}" 2>/dev/null; then
248+
range_errors+=("$key: value $val is > maximum $maxv (line ${ENV_LINE[$key]:-?})")
249+
fi
250+
fi
251+
fi
252+
93253
done
94254

255+
# -----------------------------
256+
# Reporting
257+
# -----------------------------
258+
259+
had_errors=false
260+
95261
if (( ${#missing[@]} > 0 )); then
262+
had_errors=true
96263
log_error "Missing required keys:"
97264
for key in "${missing[@]}"; do
98265
echo " - $key"
99266
done
100267
fi
101268

102269
if (( ${#unknown[@]} > 0 )); then
270+
had_errors=true
103271
log_error "Unknown keys not defined in schema:"
104272
for key in "${unknown[@]}"; do
105-
echo " - $key"
273+
echo " - $key (line ${ENV_LINE[$key]:-?})"
106274
done
107275
fi
108276

109277
if (( ${#type_errors[@]} > 0 )); then
278+
had_errors=true
110279
log_error "Type validation errors:"
111280
for err in "${type_errors[@]}"; do
112281
echo " - $err"
113282
done
114283
fi
115284

116-
if (( ${#missing[@]} > 0 || ${#unknown[@]} > 0 || ${#type_errors[@]} > 0 )); then
285+
if (( ${#enum_errors[@]} > 0 )); then
286+
had_errors=true
287+
log_error "Enum validation errors:"
288+
for err in "${enum_errors[@]}"; do
289+
echo " - $err"
290+
done
291+
fi
292+
293+
if (( ${#range_errors[@]} > 0 )); then
294+
had_errors=true
295+
log_error "Range validation errors:"
296+
for err in "${range_errors[@]}"; do
297+
echo " - $err"
298+
done
299+
fi
300+
301+
if [[ "$had_errors" == "true" ]]; then
117302
echo ""
118303
log_info "Fix .env using .env.example as reference, then re-run:"
119304
echo " ./scripts/validate-env.sh"
120305
exit 2
121306
fi
122307

123308
log_success ".env matches schema: $SCHEMA_FILE"
309+
log_info "Validated env file: $ENV_FILE"
310+
log_info "Schema: $SCHEMA_FILE"
311+
log_info "Keys in env: ${#ENV_MAP[@]}"
312+
log_info "Keys in schema: ${#schema_keys[@]}"
313+
log_info "Required keys: ${#required_keys[@]}"
314+
315+
# Optional: print helpful summary of secrets (without values)
316+
secret_count=$(jq -r '.properties | to_entries[] | select(.value.secret==true) | .key' "$SCHEMA_FILE" | wc -l | tr -d ' ')
317+
if [[ "$secret_count" =~ ^[0-9]+$ ]] && (( secret_count > 0 )); then
318+
log_info "Schema marks ${secret_count} key(s) as secrets (values not printed)."
319+
fi
320+
321+
exit 0

0 commit comments

Comments
 (0)