1// Alchemy modifications are licensed under Apache-2.0.
2// This file includes third-party code; see /THIRD_PARTY_LICENSES.md.
3import {
4 HEADER_SEPARATOR,
5 MAX_HEADER_RULES,
6 MAX_LINE_LENGTH,
7 SPLAT_REGEX,
8 UNSET_OPERATOR,
9} from "./constants.ts";
10import { validateUrl } from "./validateURL.ts";
11import type {
12 HeadersRule,
13 InvalidHeadersRule,
14 ParsedHeaders,
15} from "./types.ts";
16
17// Not strictly necessary to check for all protocols-like beginnings, since _technically_ that could be a legit header (e.g. name=http, value=://I'm a value).
18// But we're checking here since some people might be caught out and it'll help 99.9% of people who get it wrong.
19// We do the proper validation in `validateUrl` anyway :)
20const LINE_IS_PROBABLY_A_PATH = new RegExp(/^([^\s]+:\/\/|^\/)/);
21
22export function parseHeaders(
23 input: string,
24 {
25 maxRules = MAX_HEADER_RULES,
26 maxLineLength = MAX_LINE_LENGTH,
27 }: { maxRules?: number; maxLineLength?: number } = {},
28): ParsedHeaders {
29 const lines = input.split("\n");
30 const rules: Array<HeadersRule> = [];
31 const invalid: Array<InvalidHeadersRule> = [];
32
33 let rule: (HeadersRule & { line: string }) | undefined = undefined;
34 // When a path line is rejected (invalid URL, multiple wildcards, etc.),
35 // we silently skip subsequent header lines until the next path line
36 // rather than emitting confusing "Path should come before header" errors.
37 let skipUntilNextPath = false;
38
39 for (let i = 0; i < lines.length; i++) {
40 const line = (lines[i] || "").trim();
41 if (line.length === 0 || line.startsWith("#")) {
42 continue;
43 }
44
45 if (line.length > maxLineLength) {
46 invalid.push({
47 message: `Ignoring line ${
48 i + 1
49 } as it exceeds the maximum allowed length of ${maxLineLength}.`,
50 });
51 continue;
52 }
53
54 if (LINE_IS_PROBABLY_A_PATH.test(line)) {
55 skipUntilNextPath = false;
56
57 if (rules.length >= maxRules) {
58 invalid.push({
59 message: `Maximum number of rules supported is ${maxRules}. Skipping remaining ${
60 lines.length - i
61 } lines of file.`,
62 });
63 break;
64 }
65
66 if (rule) {
67 if (isValidRule(rule)) {
68 rules.push({
69 path: rule.path,
70 headers: rule.headers,
71 unsetHeaders: rule.unsetHeaders,
72 });
73 } else {
74 invalid.push({
75 line: rule.line,
76 lineNumber: i + 1,
77 message: "No headers specified",
78 });
79 }
80 }
81
82 const [path, pathError] = validateUrl(line, false, true);
83 if (pathError) {
84 invalid.push({
85 line,
86 lineNumber: i + 1,
87 message: pathError,
88 });
89 rule = undefined;
90 skipUntilNextPath = true;
91 continue;
92 }
93
94 const wildcardError = validateNoMultipleWildcards(path as string);
95 if (wildcardError) {
96 invalid.push({
97 line,
98 lineNumber: i + 1,
99 message: wildcardError,
100 });
101 rule = undefined;
102 skipUntilNextPath = true;
103 continue;
104 }
105
106 rule = {
107 path: path as string,
108 line,
109 headers: {},
110 unsetHeaders: [],
111 };
112 continue;
113 }
114
115 if (skipUntilNextPath) {
116 continue;
117 }
118
119 if (!line.includes(HEADER_SEPARATOR)) {
120 if (!rule) {
121 invalid.push({
122 line,
123 lineNumber: i + 1,
124 message: "Expected a path beginning with at least one forward-slash",
125 });
126 } else {
127 if (line.trim().startsWith(UNSET_OPERATOR)) {
128 rule.unsetHeaders.push(line.trim().replace(UNSET_OPERATOR, ""));
129 } else {
130 invalid.push({
131 line,
132 lineNumber: i + 1,
133 message:
134 "Expected a colon-separated header pair (e.g. name: value)",
135 });
136 }
137 }
138 continue;
139 }
140
141 const [rawName, ...rawValue] = line.split(HEADER_SEPARATOR);
142 const name = (rawName || "").trim().toLowerCase();
143
144 if (name.includes(" ")) {
145 invalid.push({
146 line,
147 lineNumber: i + 1,
148 message: "Header name cannot include spaces",
149 });
150 continue;
151 }
152
153 const value = rawValue.join(HEADER_SEPARATOR).trim();
154
155 if (name === "") {
156 invalid.push({
157 line,
158 lineNumber: i + 1,
159 message: "No header name specified",
160 });
161 continue;
162 }
163
164 if (value === "") {
165 invalid.push({
166 line,
167 lineNumber: i + 1,
168 message: "No header value specified",
169 });
170 continue;
171 }
172
173 if (!rule) {
174 invalid.push({
175 line,
176 lineNumber: i + 1,
177 message: `Path should come before header (${name}: ${value})`,
178 });
179 continue;
180 }
181
182 const existingValues = rule.headers[name];
183 rule.headers[name] = existingValues ? `${existingValues}, ${value}` : value;
184 }
185
186 if (rule) {
187 if (isValidRule(rule)) {
188 rules.push({
189 path: rule.path,
190 headers: rule.headers,
191 unsetHeaders: rule.unsetHeaders,
192 });
193 } else {
194 invalid.push({ line: rule.line, message: "No headers specified" });
195 }
196 }
197
198 return {
199 rules,
200 invalid,
201 };
202}
203
204function isValidRule(rule: HeadersRule) {
205 return Object.keys(rule.headers).length > 0 || rule.unsetHeaders.length > 0;
206}
207
208/**
209 * At runtime, `*` wildcards are converted to `:splat` placeholders. This means
210 * a path with multiple wildcards, or a wildcard combined with an explicit
211 * `:splat` placeholder, would result in duplicate `:splat` parameters which is
212 * unsupported.
213 */
214function validateNoMultipleWildcards(path: string): string | undefined {
215 const wildcardCount = (path.match(SPLAT_REGEX) ?? []).length;
216 const hasSplatPlaceholder = /:splat(?!\w)/.test(path);
217
218 if (wildcardCount > 1) {
219 return `Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping ${path}.`;
220 }
221
222 if (wildcardCount > 0 && hasSplatPlaceholder) {
223 return `Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping ${path}.`;
224 }
225
226 return undefined;
227}