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
410set -euo pipefail
511
@@ -12,112 +18,304 @@ RED='\033[0;31m'
1218GREEN=' \033[0;32m'
1319YELLOW=' \033[1;33m'
1420BLUE=' \033[0;34m'
21+ CYAN=' \033[0;36m'
22+ BOLD=' \033[1m'
1523NC=' \033[0m'
1624
1725log_info () { echo -e " ${BLUE} [INFO]${NC} $1 " ; }
1826log_success () { echo -e " ${GREEN} [SUCCESS]${NC} $1 " ; }
1927log_warn () { echo -e " ${YELLOW} [WARN]${NC} $1 " ; }
2028log_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+
2253if [[ ! -f " $ENV_FILE " ]]; then
2354 log_error " Env file not found: $ENV_FILE "
24- exit 1
55+ exit 3
2556fi
2657
2758if [[ ! -f " $SCHEMA_FILE " ]]; then
2859 log_error " Schema file not found: $SCHEMA_FILE "
29- exit 1
60+ exit 3
3061fi
3162
3263if ! 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
3567fi
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+
3775declare -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 "
45152done < " $ENV_FILE "
46153
154+ # -----------------------------
155+ # Schema prep
156+ # -----------------------------
157+
47158missing= ()
48159unknown= ()
49160type_errors= ()
161+ enum_errors= ()
162+ range_errors= ()
50163
51164mapfile -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+
52176for key in " ${required_keys[@]} " ; do
53177 val=" ${ENV_MAP[$key]-} "
54178 if [[ -z " $val " ]]; then
55179 missing+=(" $key " )
56180 fi
57181done
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
65187for key in " ${! ENV_MAP[@]} " ; do
66188 if [[ -z " ${SCHEMA_KEY_SET[$key]-} " ]]; then
67189 unknown+=(" $key " )
68190 fi
69191done
70192
193+ # -----------------------------
194+ # Type + enum + range checks
195+ # -----------------------------
196+
71197for 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+
93253done
94254
255+ # -----------------------------
256+ # Reporting
257+ # -----------------------------
258+
259+ had_errors= false
260+
95261if (( ${# missing[@]} > 0 )) ; then
262+ had_errors=true
96263 log_error " Missing required keys:"
97264 for key in " ${missing[@]} " ; do
98265 echo " - $key "
99266 done
100267fi
101268
102269if (( ${# 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
107275fi
108276
109277if (( ${# 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
114283fi
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
121306fi
122307
123308log_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