@fuzdev/fuz_app 0.144.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,104 +5,229 @@
5
5
  * nginx location being `internal;` — only the authz'd handler's redirect (an
6
6
  * internal subrequest) may reach it. A *public* facts location would serve any
7
7
  * fact's bytes to anyone who guesses the `<shard>/<rest>` path, bypassing every
8
- * cell-visibility check. `XAccelConfig` makes that assertion structural: a
9
- * consumer can only obtain the redirect prefix by building an `XAccelConfig`,
10
- * which runs this check and throws **loudly** on a missing or non-`internal;`
11
- * location — it cannot silently fall closed.
8
+ * cell-visibility check. A location with its own `add_header` also stops the
9
+ * server-level headers from applying, so the facts location must carry the fact
10
+ * hardening headers itself (`fact_security_header_directives` in
11
+ * `server/fact_security_headers.ts`).
12
12
  *
13
- * This is a best-effort string check (brace-matched `location` blocks), the TS
14
- * twin of the Rust `fuz_fact_serving` `nginx.rs` — not a real nginx parser, but
15
- * it catches the security-critical omission. This is the only nginx property
16
- * fuz_app validates; there is no general config validator.
13
+ * `XAccelConfig` makes these assertions structural: a consumer can only obtain
14
+ * the redirect prefix by building an `XAccelConfig`, which runs
15
+ * `validate_facts_internal_location` and throws **loudly** on any violation — it
16
+ * cannot silently fall closed.
17
+ *
18
+ * ## Rules
19
+ *
20
+ * The config is tokenized as nginx reads it (`ngx_conf_read_token`): whitespace
21
+ * is exactly space, tab, CR and LF; an unquoted word ends only at those or at
22
+ * `;` / `{` (a `}` inside a word is part of it, and so is a `{` straight after
23
+ * `$`); `#` starts a comment only where a token would start; quoted strings are
24
+ * one word, with nginx's backslash escapes. It is then parsed into nested
25
+ * blocks, and every `location` at any depth, in every `server` block, is
26
+ * classified against the prefix (trailing slashes ignored). A location *serves
27
+ * the prefix* when it is a prefix-type location (no modifier, or `^~`) whose
28
+ * path is the prefix or below it. Each one must:
29
+ *
30
+ * - use `^~` — a plain prefix location is refused;
31
+ * - be a direct child of a `server` block — one nested in another location is
32
+ * refused;
33
+ * - carry, as top-level directives of its own body, an exact `internal;` and
34
+ * each `fact_security_header_directives` line (token-equal: whitespace and
35
+ * quoting are free, the words are not);
36
+ * - hold only simple directives — any nested block (`location`, `if`,
37
+ * `limit_except`, …) is refused, and so is `include`, since included files
38
+ * are not followed.
39
+ *
40
+ * The first two rules are what keep regex locations out: nginx never evaluates
41
+ * regex locations for a path whose longest prefix match is a `^~` location at
42
+ * server level, and because every prefix location at or below the prefix must
43
+ * be such a location, the longest match for any redirected path always is. (A
44
+ * longer plain location below the prefix can't exist — the `^~` rule refuses
45
+ * it.)
46
+ *
47
+ * The prefix may appear only in its own location lines and their bodies:
48
+ * `internal;` admits every internal redirect and subrequest, so any other
49
+ * directive with a word naming the prefix (trailing slash trimmed, so
50
+ * `/_facts$uri` counts) is refused, in any block at any depth — a `rewrite`,
51
+ * `try_files`, `error_page`, `index`, `add_before_body`, or a `set` / `map`
52
+ * value that a redirect later reads. The only exemptions are each `location`'s
53
+ * own modifier and path words and the bodies of the locations serving the
54
+ * prefix.
55
+ *
56
+ * Also refused: an `=` location on the prefix or below it (it can't serve the
57
+ * nested paths the redirect emits); a regex location (`~` / `~*`) whose pattern
58
+ * spells out the prefix (after unescaping `\/`, ASCII case-folded for `~*`) —
59
+ * dead under the rules above, refused for the clearer message; and a config
60
+ * with no location serving the prefix. `include` at server level is allowed,
61
+ * but its file is not read — keep facts-related locations out of included
62
+ * files.
63
+ *
64
+ * The threat model is operator mistakes, not an adversarial config author: the
65
+ * prefix check is textual, so a value assembled from pieces (`/_fac$1`) evades
66
+ * it. Beyond the config text:
67
+ *
68
+ * - The validator checks URL paths only — another `root` or `alias` that maps
69
+ * onto the facts directory would serve its files publicly, so keep the facts
70
+ * directory outside every other location's root.
71
+ * - nginx honours `X-Accel-Redirect` from any proxied upstream (`proxy_pass`,
72
+ * `fastcgi_pass`, `uwsgi_pass`, `scgi_pass`, `grpc_pass`), so every upstream in
73
+ * the server is trusted to point into the prefix; an untrusted one needs
74
+ * `proxy_ignore_headers X-Accel-Redirect;` (or the `fastcgi_` / `uwsgi_` /
75
+ * `scgi_` equivalent).
76
+ * - Module code can redirect internally too (ssi `include virtual`, njs
77
+ * `internalRedirect`, perl, echo), so run trusted modules and content only.
78
+ *
79
+ * The Rust twin is `fuz_fact_serving`'s `nginx.rs`, held to the same rules and
80
+ * the same fixture corpus.
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const x_accel = create_x_accel_config(
85
+ * '/_facts/',
86
+ * `server {
87
+ * location ^~ /_facts/ {
88
+ * internal;
89
+ * alias /srv/app/facts/;
90
+ * ${fact_security_header_directives().join('\n\t\t')}
91
+ * }
92
+ * }`
93
+ * );
94
+ * ```
17
95
  *
18
96
  * @module
19
97
  */
20
98
  import { z } from 'zod';
21
- /** Strip every trailing `/` so `/_facts/` and `/_facts` compare equal. */
22
- const trim_trailing_slashes = (value) => value.replace(/\/+$/, '');
23
- /** `true` if a location block body contains an `internal` directive. */
24
- const is_internal = (content) => content.split(/[;\s]+/).some((token) => token === 'internal');
99
+ import { FACT_SECURITY_HEADERS, fact_security_header_directives } from './fact_security_headers.js';
25
100
  /**
26
- * Extract `{path, content}` for each `location … { … }` block, brace-matched so
27
- * nested blocks are kept whole. `path` is the last whitespace token before the
28
- * opening brace (the location path, after any modifier). Best-effort: a stray
29
- * `location` token in a comment can produce a spurious block, harmless for this
30
- * check.
31
- */
32
- const find_location_blocks = (config) => {
33
- const blocks = [];
34
- let search = 0;
35
- let kw;
36
- while ((kw = config.indexOf('location', search)) !== -1) {
37
- // Require a token boundary before `location` (start or whitespace) so
38
- // `relocation` etc. don't match.
39
- const boundary_ok = kw === 0 || /\s/.test(config[kw - 1]);
40
- const after = kw + 'location'.length;
41
- const brace = config.indexOf('{', after);
42
- if (brace === -1)
43
- break;
44
- if (!boundary_ok) {
45
- search = after;
46
- continue;
47
- }
48
- const header = config.slice(after, brace).trim();
49
- const tokens = header.split(/\s+/).filter((token) => token.length > 0);
50
- const path = tokens.length > 0 ? tokens[tokens.length - 1] : '';
51
- // Brace-match the body.
52
- let depth = 1;
53
- let i = brace + 1;
54
- while (i < config.length && depth > 0) {
55
- const ch = config[i];
56
- if (ch === '{')
57
- depth++;
58
- else if (ch === '}')
59
- depth--;
60
- i++;
61
- }
62
- blocks.push({ path, content: config.slice(brace, i) });
63
- search = i;
64
- }
65
- return blocks;
66
- };
67
- /**
68
- * Assert the nginx `location` serving the X-Accel facts prefix is `internal;`.
101
+ * Assert the nginx config serves the X-Accel facts prefix only from `internal;`
102
+ * locations carrying the fact hardening headers. See the module docs for the
103
+ * rules; every violation is collected, so one call reports them all.
69
104
  *
70
- * `facts_location` is the path the X-Accel redirect prefix points at (e.g.
71
- * `/_facts/`). Returns a fatal error when no matching `location` block exists,
72
- * or when the matching block is not marked `internal;` — either is a public
73
- * facts location that bypasses cell visibility.
74
- *
75
- * @param config - the nginx config template string to check
76
- * @param facts_location - the facts location path the redirect prefix points at
77
- * @returns `{ok, errors}` — `ok` is `true` only when the location exists and is `internal;`
105
+ * @param config - the nginx config string to check
106
+ * @param facts_location - the facts location path the redirect prefix points at (e.g. `/_facts/`)
107
+ * @returns `{ok, errors}` — `ok` is `true` only when every rule passes
78
108
  */
79
109
  export const validate_facts_internal_location = (config, facts_location) => {
80
- const errors = [];
81
- const normalized = trim_trailing_slashes(facts_location);
82
- const blocks = find_location_blocks(config);
83
- const matching = blocks.filter((block) => trim_trailing_slashes(block.path) === normalized);
84
- if (matching.length === 0) {
85
- errors.push(`No nginx \`location ${facts_location}\` block found — the X-Accel facts location must ` +
86
- `exist and be marked \`internal;\` so only the authz'd handler's redirect can reach it. ` +
87
- `A public facts location bypasses every cell-visibility check.`);
110
+ let errors;
111
+ try {
112
+ errors = collect_errors(config, facts_location);
88
113
  }
89
- else if (!matching.some((block) => is_internal(block.content))) {
90
- errors.push(`nginx \`location ${facts_location}\` is NOT marked \`internal;\` — a public facts ` +
91
- `location serves any fact's bytes to anyone who guesses the path, bypassing every ` +
92
- `cell-visibility check. Add \`internal;\` to the block.`);
114
+ catch (err) {
115
+ if (!(err instanceof NginxParseError))
116
+ throw err;
117
+ errors = [
118
+ `could not parse the nginx config (${err.message}) — the X-Accel facts location ` +
119
+ `can't be verified \`internal;\`.`
120
+ ];
93
121
  }
94
122
  return { ok: errors.length === 0, errors };
95
123
  };
124
+ const collect_errors = (config, facts_location) => {
125
+ const prefix = trim_trailing_slashes(facts_location);
126
+ if (!facts_location.startsWith('/') || prefix === '') {
127
+ return [
128
+ `X-Accel redirect prefix \`${facts_location}\` must be an absolute path below \`/\` ` +
129
+ `(e.g. \`/_facts/\`).`
130
+ ];
131
+ }
132
+ const directives = parse_directives(tokenize(config));
133
+ const locations = [];
134
+ collect_locations(directives, null, locations);
135
+ const required = Object.entries(FACT_SECURITY_HEADERS).map(([name, value]) => [
136
+ 'add_header',
137
+ name,
138
+ value,
139
+ 'always'
140
+ ]);
141
+ const rendered = fact_security_header_directives();
142
+ const errors = [];
143
+ let matched = false;
144
+ for (const location of locations) {
145
+ const label = location_label(location);
146
+ switch (location.modifier) {
147
+ case 'named':
148
+ break;
149
+ case 'prefix':
150
+ case 'prefix_no_regex': {
151
+ if (!path_is_under(location.path, prefix))
152
+ break;
153
+ matched = true;
154
+ if (location.modifier !== 'prefix_no_regex') {
155
+ errors.push(`${label} must use \`^~\` (\`location ^~ ${location.path}\`) — on a plain prefix ` +
156
+ `location nginx goes on to check regex locations, any of which can serve fact ` +
157
+ `bytes publicly.`);
158
+ }
159
+ if (location.parent !== 'server') {
160
+ errors.push(`${label} must be a direct child of a \`server\` block — inside another ` +
161
+ `block, \`^~\` doesn't stop nginx checking regex locations.`);
162
+ }
163
+ if (!has_directive(location.body, ['internal'])) {
164
+ errors.push(`${label} is NOT marked \`internal;\` — a public facts location serves any ` +
165
+ `fact's bytes to anyone who guesses the path, bypassing every ` +
166
+ `cell-visibility check. Add \`internal;\` as a directive of the block itself.`);
167
+ }
168
+ for (let i = 0; i < required.length; i++) {
169
+ if (!has_directive(location.body, required[i])) {
170
+ errors.push(`${label} lacks \`${rendered[i]}\` — a location's own \`add_header\` replaces the ` +
171
+ `server-level headers, so without it fact bytes go out without that ` +
172
+ `hardening header.`);
173
+ }
174
+ }
175
+ for (const directive of location.body) {
176
+ const name = directive.args[0] ?? '';
177
+ if (directive.block !== null) {
178
+ errors.push(`${label} contains a nested \`${name}\` block (line ${directive.line}) — the facts ` +
179
+ `location must hold only simple directives, since a nested block can ` +
180
+ `serve or re-header the bytes.`);
181
+ }
182
+ else if (name === 'include') {
183
+ errors.push(`${label} contains \`include\` (line ${directive.line}) — included files are not ` +
184
+ `followed, so the facts location can't contain one.`);
185
+ }
186
+ }
187
+ break;
188
+ }
189
+ case 'exact':
190
+ if (path_is_under(location.path, prefix)) {
191
+ errors.push(`${label} is an exact-match location on the facts prefix — it can't serve ` +
192
+ `the nested \`<shard>/<rest>\` paths the redirect emits, and one below the ` +
193
+ `prefix shadows the internal location. Use a \`^~\` location.`);
194
+ }
195
+ break;
196
+ case 'regex':
197
+ case 'regex_caseless': {
198
+ const pattern = location.path.replaceAll('\\/', '/');
199
+ const shadows = location.modifier === 'regex_caseless'
200
+ ? to_ascii_lowercase(pattern).includes(to_ascii_lowercase(prefix))
201
+ : pattern.includes(prefix);
202
+ if (shadows) {
203
+ errors.push(`${label} is a regex location naming the facts prefix — remove it; the ` +
204
+ `facts location must be a server-level \`^~\` location, which nginx ` +
205
+ `serves without checking regex locations.`);
206
+ }
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ collect_prefix_mention_errors(directives, prefix, errors);
212
+ if (!matched) {
213
+ errors.unshift(`No nginx \`location ^~ ${facts_location}\` block found — the X-Accel facts ` +
214
+ `location must exist and be marked \`internal;\` so only the authz'd handler's ` +
215
+ `redirect can reach it. A public facts location bypasses every cell-visibility ` +
216
+ `check.`);
217
+ }
218
+ return errors;
219
+ };
96
220
  /**
97
221
  * A validated X-Accel redirect configuration — the only handle that enables the
98
222
  * `X-Accel-Redirect` serving path in `server/serve_fact_route.ts`.
99
223
  *
100
224
  * The redirect prefix can be obtained **only** by passing the nginx config
101
225
  * through `validate_facts_internal_location` (via `create_x_accel_config`), so
102
- * X-Accel serving is impossible to enable without proving the facts location is
103
- * `internal;` at boot — a public facts location would bypass every
104
- * cell-visibility check. A Zod-branded type: the brand can't be forged without
105
- * an explicit cast, so the factory is the only ordinary construction path.
226
+ * X-Accel serving is impossible to enable without proving at boot that the
227
+ * facts location is `internal;` and carries the fact hardening headers — a
228
+ * public facts location would bypass every cell-visibility check. A
229
+ * Zod-branded type: the brand can't be forged without an explicit cast, so the
230
+ * factory is the only ordinary construction path.
106
231
  */
107
232
  export const XAccelConfig = z
108
233
  .strictObject({
@@ -124,18 +249,18 @@ export class XAccelConfigError extends Error {
124
249
  }
125
250
  }
126
251
  /**
127
- * Build a validated `XAccelConfig`, asserting the nginx `location` serving
128
- * `redirect_prefix` is `internal;`.
252
+ * Build a validated `XAccelConfig`, asserting the nginx config serves
253
+ * `redirect_prefix` only from safe locations (the module-level rules).
129
254
  *
130
255
  * The make-impossible-states gate: serving can only emit `X-Accel-Redirect`
131
- * into a location proven `internal;` at boot.
256
+ * into a location proven `internal;` and header-carrying at boot.
132
257
  *
133
258
  * @param redirect_prefix - the X-Accel redirect prefix (e.g. `/_facts/`)
134
- * @param nginx_config - the nginx config template string to validate against
259
+ * @param nginx_config - the nginx config string to validate against
135
260
  * @returns the validated `XAccelConfig` carrying `redirect_prefix`
136
- * @throws `XAccelConfigError` when `nginx_config` has no matching `location` for
137
- * `redirect_prefix`, or the matching block is not marked `internal;` — either
138
- * is a public facts location that bypasses every cell-visibility check.
261
+ * @throws `XAccelConfigError` carrying every violation
262
+ * `validate_facts_internal_location` finds — a missing, public, header-less,
263
+ * exact-match, or regex-shadowed facts location.
139
264
  */
140
265
  export const create_x_accel_config = (redirect_prefix, nginx_config) => {
141
266
  const validation = validate_facts_internal_location(nginx_config, redirect_prefix);
@@ -144,3 +269,325 @@ export const create_x_accel_config = (redirect_prefix, nginx_config) => {
144
269
  }
145
270
  return XAccelConfig.parse({ redirect_prefix });
146
271
  };
272
+ /** Strip every trailing `/` so `/_facts/` and `/_facts` compare equal. */
273
+ const trim_trailing_slashes = (value) => value.replace(/\/+$/, '');
274
+ /** ASCII-only lowercasing, so both twins fold exactly the same characters. */
275
+ const to_ascii_lowercase = (value) => value.replace(/[A-Z]/g, (c) => c.toLowerCase());
276
+ /** `true` when `path` is `prefix` or below it, trailing slashes ignored. */
277
+ const path_is_under = (path, prefix) => {
278
+ const trimmed = trim_trailing_slashes(path);
279
+ return trimmed === prefix || (trimmed.startsWith(prefix) && trimmed[prefix.length] === '/');
280
+ };
281
+ /**
282
+ * `true` when `body` has a top-level simple directive whose words are exactly
283
+ * `args` (a block directive with those words doesn't count).
284
+ */
285
+ const has_directive = (body, args) => body.some((d) => d.block === null && d.args.length === args.length && d.args.every((a, i) => a === args[i]));
286
+ const MODIFIER_TEXT = {
287
+ prefix: '',
288
+ named: '',
289
+ prefix_no_regex: '^~ ',
290
+ exact: '= ',
291
+ regex: '~ ',
292
+ regex_caseless: '~* '
293
+ };
294
+ /** The modifiers nginx accepts as a separate word (`= /x`). */
295
+ const SEPARATE_MODIFIERS = new Map([
296
+ ['=', 'exact'],
297
+ ['^~', 'prefix_no_regex'],
298
+ ['~', 'regex'],
299
+ ['~*', 'regex_caseless']
300
+ ]);
301
+ /**
302
+ * The modifiers nginx accepts glued to the path (`=/x`), longest first so `^~`
303
+ * / `~*` aren't read as `~`.
304
+ */
305
+ const GLUED_MODIFIERS = [
306
+ ['^~', 'prefix_no_regex'],
307
+ ['~*', 'regex_caseless'],
308
+ ['~', 'regex'],
309
+ ['=', 'exact']
310
+ ];
311
+ const location_label = (location) => `\`location ${MODIFIER_TEXT[location.modifier]}${location.path}\` (line ${location.line})`;
312
+ /**
313
+ * Classify a `location`'s arguments (after the keyword) — the separate (`= /x`)
314
+ * and glued (`=/x`) modifier forms nginx accepts.
315
+ */
316
+ const parse_location_args = (args) => {
317
+ if (args.length === 2) {
318
+ const modifier = SEPARATE_MODIFIERS.get(args[0]);
319
+ return modifier === undefined ? null : { modifier, path: args[1] };
320
+ }
321
+ if (args.length === 1) {
322
+ const glued = args[0];
323
+ if (glued.startsWith('@'))
324
+ return { modifier: 'named', path: glued };
325
+ for (const [text, modifier] of GLUED_MODIFIERS) {
326
+ if (glued.startsWith(text))
327
+ return { modifier, path: glued.slice(text.length) };
328
+ }
329
+ return { modifier: 'prefix', path: glued };
330
+ }
331
+ return null;
332
+ };
333
+ /**
334
+ * Refuse every directive, in any block at any depth, with a word containing the
335
+ * prefix (trailing slash trimmed, so `/_facts$uri` counts) — the prefix may
336
+ * appear only in its own location lines and their bodies. Exempt: each
337
+ * `location` directive's own modifier and path words, and the bodies of the
338
+ * locations serving the prefix (held to simple directives elsewhere). A textual
339
+ * heuristic against operator mistakes: a value assembled from pieces
340
+ * (`/_fac$1`) is not caught.
341
+ */
342
+ const collect_prefix_mention_errors = (directives, prefix, errors) => {
343
+ for (const directive of directives) {
344
+ const name = directive.args[0] ?? '';
345
+ if (name === 'location') {
346
+ const parsed = parse_location_args(directive.args.slice(1));
347
+ const serves_prefix = parsed !== null &&
348
+ (parsed.modifier === 'prefix' || parsed.modifier === 'prefix_no_regex') &&
349
+ path_is_under(parsed.path, prefix);
350
+ if (directive.block !== null && !serves_prefix) {
351
+ collect_prefix_mention_errors(directive.block, prefix, errors);
352
+ }
353
+ continue;
354
+ }
355
+ if (directive.args.some((word) => word.includes(prefix))) {
356
+ errors.push(`\`${name}\` (line ${directive.line}) names the facts prefix — the prefix may appear only in its ` +
357
+ `own location lines and their bodies; anything else naming it (a redirect, a ` +
358
+ `subrequest, a variable that becomes one) can serve fact bytes publicly.`);
359
+ }
360
+ if (directive.block !== null)
361
+ collect_prefix_mention_errors(directive.block, prefix, errors);
362
+ }
363
+ };
364
+ /**
365
+ * Gather every `location` block at any depth, in document order, each with the
366
+ * name of the block directly enclosing it.
367
+ */
368
+ const collect_locations = (directives, parent, out) => {
369
+ for (const directive of directives) {
370
+ if (directive.block === null)
371
+ continue;
372
+ const name = directive.args[0] ?? '';
373
+ if (name === 'location') {
374
+ const args = directive.args.slice(1);
375
+ const parsed = parse_location_args(args);
376
+ if (parsed === null) {
377
+ throw new NginxParseError(`unrecognised \`location ${args.join(' ')}\` (line ${directive.line})`);
378
+ }
379
+ out.push({ ...parsed, line: directive.line, parent, body: directive.block });
380
+ }
381
+ collect_locations(directive.block, name, out);
382
+ }
383
+ };
384
+ /** A config the tokenizer or parser can't read — reported as a validation error. */
385
+ class NginxParseError extends Error {
386
+ }
387
+ /** nginx's config whitespace — exactly these four, nothing Unicode. */
388
+ const is_nginx_space = (ch) => ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n';
389
+ /**
390
+ * Apply nginx's word unescaping: `\"` `\'` `\\` drop the backslash, `\t` `\r`
391
+ * `\n` become the control character, any other backslash stays.
392
+ */
393
+ const unescape_word = (raw) => {
394
+ let out = '';
395
+ for (let i = 0; i < raw.length; i++) {
396
+ const c = raw[i];
397
+ if (c === '\\') {
398
+ const next = raw[i + 1];
399
+ const replacement = next === '"' || next === "'" || next === '\\'
400
+ ? next
401
+ : next === 't'
402
+ ? '\t'
403
+ : next === 'r'
404
+ ? '\r'
405
+ : next === 'n'
406
+ ? '\n'
407
+ : undefined;
408
+ if (replacement !== undefined) {
409
+ out += replacement;
410
+ i++;
411
+ continue;
412
+ }
413
+ }
414
+ out += c;
415
+ }
416
+ return out;
417
+ };
418
+ /**
419
+ * Tokenize as nginx's `ngx_conf_read_token` reads a config — the same state
420
+ * machine (`last_space`, `need_space`, `quoted`, `variable`, the quote and
421
+ * comment flags), producing words and the `;` `{` `}` punctuation. Iterates
422
+ * code points, as the Rust twin iterates `char`s.
423
+ */
424
+ const tokenize = (config) => {
425
+ const tokens = [];
426
+ let line = 1;
427
+ let start_line = 1;
428
+ let word = '';
429
+ let last_space = true;
430
+ let need_space = false;
431
+ let quoted = false;
432
+ let s_quoted = false;
433
+ let d_quoted = false;
434
+ let sharp_comment = false;
435
+ let variable = false;
436
+ for (const ch of config) {
437
+ if (ch === '\n') {
438
+ line++;
439
+ sharp_comment = false;
440
+ }
441
+ if (sharp_comment)
442
+ continue;
443
+ if (quoted) {
444
+ quoted = false;
445
+ word += ch;
446
+ continue;
447
+ }
448
+ if (need_space) {
449
+ if (is_nginx_space(ch)) {
450
+ last_space = true;
451
+ need_space = false;
452
+ continue;
453
+ }
454
+ if (ch === ';' || ch === '{') {
455
+ need_space = false;
456
+ last_space = true;
457
+ tokens.push({ kind: ch, line });
458
+ continue;
459
+ }
460
+ if (ch === ')') {
461
+ last_space = true;
462
+ need_space = false;
463
+ }
464
+ else {
465
+ throw new NginxParseError(`unexpected \`${ch}\` after a quoted string, line ${line}`);
466
+ }
467
+ }
468
+ if (last_space) {
469
+ start_line = line;
470
+ if (is_nginx_space(ch))
471
+ continue;
472
+ if (ch === ';' || ch === '{' || ch === '}') {
473
+ tokens.push({ kind: ch, line });
474
+ }
475
+ else if (ch === '#') {
476
+ sharp_comment = true;
477
+ }
478
+ else if (ch === '"') {
479
+ d_quoted = true;
480
+ last_space = false;
481
+ }
482
+ else if (ch === "'") {
483
+ s_quoted = true;
484
+ last_space = false;
485
+ }
486
+ else {
487
+ if (ch === '\\') {
488
+ quoted = true;
489
+ }
490
+ else if (ch === '$') {
491
+ variable = true;
492
+ }
493
+ last_space = false;
494
+ word += ch;
495
+ }
496
+ continue;
497
+ }
498
+ if (ch === '{' && variable) {
499
+ word += ch;
500
+ continue;
501
+ }
502
+ variable = false;
503
+ if (ch === '\\') {
504
+ quoted = true;
505
+ word += ch;
506
+ continue;
507
+ }
508
+ if (ch === '$') {
509
+ variable = true;
510
+ word += ch;
511
+ continue;
512
+ }
513
+ let found = false;
514
+ if (d_quoted) {
515
+ if (ch === '"') {
516
+ d_quoted = false;
517
+ need_space = true;
518
+ found = true;
519
+ }
520
+ }
521
+ else if (s_quoted) {
522
+ if (ch === "'") {
523
+ s_quoted = false;
524
+ need_space = true;
525
+ found = true;
526
+ }
527
+ }
528
+ else if (is_nginx_space(ch) || ch === ';' || ch === '{') {
529
+ last_space = true;
530
+ found = true;
531
+ }
532
+ if (!found) {
533
+ word += ch;
534
+ continue;
535
+ }
536
+ tokens.push({ kind: 'word', text: unescape_word(word), line: start_line });
537
+ word = '';
538
+ if (ch === ';' || ch === '{')
539
+ tokens.push({ kind: ch, line });
540
+ }
541
+ if (!last_space) {
542
+ throw new NginxParseError(`unexpected end of file inside a word, line ${line}`);
543
+ }
544
+ return tokens;
545
+ };
546
+ const parse_directives = (tokens) => {
547
+ const cursor = { pos: 0 };
548
+ return parse_block(tokens, cursor, null);
549
+ };
550
+ const parse_block = (tokens, cursor, open_line) => {
551
+ const out = [];
552
+ for (;;) {
553
+ const token = tokens[cursor.pos];
554
+ if (token === undefined) {
555
+ if (open_line !== null) {
556
+ throw new NginxParseError(`unclosed \`{\` opened on line ${open_line}`);
557
+ }
558
+ return out;
559
+ }
560
+ if (token.kind === '}') {
561
+ if (open_line === null)
562
+ throw new NginxParseError(`unexpected \`}\` on line ${token.line}`);
563
+ cursor.pos++;
564
+ return out;
565
+ }
566
+ if (token.kind !== 'word') {
567
+ throw new NginxParseError(`unexpected \`${token.kind}\` on line ${token.line}`);
568
+ }
569
+ const line = token.line;
570
+ const args = [];
571
+ for (;;) {
572
+ const next = tokens[cursor.pos];
573
+ if (next?.kind === 'word') {
574
+ args.push(next.text);
575
+ cursor.pos++;
576
+ }
577
+ else if (next?.kind === ';') {
578
+ cursor.pos++;
579
+ out.push({ args, line, block: null });
580
+ break;
581
+ }
582
+ else if (next?.kind === '{') {
583
+ cursor.pos++;
584
+ const block = parse_block(tokens, cursor, next.line);
585
+ out.push({ args, line, block });
586
+ break;
587
+ }
588
+ else {
589
+ throw new NginxParseError(`directive \`${args.join(' ')}\` on line ${line} is missing its \`;\``);
590
+ }
591
+ }
592
+ }
593
+ };