@bakobo/fiki 0.0.1 → 0.7.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.
package/src/sfv.js ADDED
@@ -0,0 +1,219 @@
1
+ // The RFC 8941 subset RFC 9421 actually uses (`this.i` @2q9gv70t).
2
+ //
3
+ // Hand-rolled rather than depended upon, because fiki's slice of structured fields is small and
4
+ // CLOSED — a dictionary whose members are inner lists of strings with parameters, plus byte
5
+ // sequences — and because a browser library with no dependencies ships as one file with no supply
6
+ // chain. The usual argument against writing your own parser holds where the grammar is open-ended;
7
+ // this one's entire output surface is pinned byte for byte by the shared vectors.
8
+ //
9
+ // What is deliberately NOT here: decimals, tokens, and every field type RFC 9421 never puts in
10
+ // these two headers. A parser that accepts less than the spec can only refuse things fiki would
11
+ // not have understood anyway, which is the safe direction. Inner-list items DO carry parameters,
12
+ // because RFC 9421 section 2.4's `req` is one, and fiki decides what a parameter means only after
13
+ // the parse, where refusing it can name the component rather than the syntax (@7f28p7xk).
14
+
15
+ // Thrown by this module alone and never exported: the parser cannot know WHICH header it is
16
+ // reading, and the taxonomy distinguishes an unparsable Signature from an unparsable
17
+ // Signature-Input. So callers catch this and re-raise the class that names the header, exactly as
18
+ // the Python port wraps http_sfv (@8zw78n0v).
19
+ export class SfvSyntaxError extends Error {}
20
+
21
+ const MalformedSyntax = SfvSyntaxError;
22
+
23
+ import { fromBase64, toBase64 } from './bytes.js';
24
+
25
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
26
+
27
+ class Cursor {
28
+ constructor(text) {
29
+ this.text = text;
30
+ this.at = 0;
31
+ }
32
+
33
+ get done() {
34
+ return this.at >= this.text.length;
35
+ }
36
+
37
+ peek() {
38
+ return this.text[this.at];
39
+ }
40
+
41
+ take() {
42
+ return this.text[this.at++];
43
+ }
44
+
45
+ skipSpace() {
46
+ while (!this.done && (this.peek() === ' ' || this.peek() === '\t')) this.at += 1;
47
+ }
48
+
49
+ expect(char) {
50
+ if (this.done || this.peek() !== char) {
51
+ throw new MalformedSyntax(`Expected "${char}" at offset ${this.at} of ${this.text}.`);
52
+ }
53
+ this.at += 1;
54
+ }
55
+ }
56
+
57
+ function parseKey(cursor) {
58
+ // RFC 8941 section 3.1.2: lcalpha / "*" to start, then lcalpha / digit / "_" / "-" / "." / "*".
59
+ const start = cursor.at;
60
+ if (!/[a-z*]/.test(cursor.peek() ?? '')) {
61
+ throw new MalformedSyntax(`A key must start with a lowercase letter or "*", at offset ${start}.`);
62
+ }
63
+ while (!cursor.done && /[a-z0-9_\-.*]/.test(cursor.peek())) cursor.at += 1;
64
+ return cursor.text.slice(start, cursor.at);
65
+ }
66
+
67
+ function parseString(cursor) {
68
+ cursor.expect('"');
69
+ let out = '';
70
+ while (!cursor.done) {
71
+ const char = cursor.take();
72
+ if (char === '\\') {
73
+ const escaped = cursor.take();
74
+ if (escaped !== '"' && escaped !== '\\') {
75
+ throw new MalformedSyntax(`Only \\" and \\\\ may be escaped in a string, not \\${escaped}.`);
76
+ }
77
+ out += escaped;
78
+ } else if (char === '"') {
79
+ return out;
80
+ } else {
81
+ out += char;
82
+ }
83
+ }
84
+ throw new MalformedSyntax('A string ran to the end of the field without closing.');
85
+ }
86
+
87
+ function parseByteSequence(cursor) {
88
+ cursor.expect(':');
89
+ const start = cursor.at;
90
+ while (!cursor.done && cursor.peek() !== ':') cursor.at += 1;
91
+ const encoded = cursor.text.slice(start, cursor.at);
92
+ cursor.expect(':');
93
+ if (!BASE64.test(encoded)) {
94
+ throw new MalformedSyntax('A byte sequence must be base64 between colons.');
95
+ }
96
+ return fromBase64(encoded);
97
+ }
98
+
99
+ function parseInteger(cursor) {
100
+ const start = cursor.at;
101
+ if (cursor.peek() === '-') cursor.at += 1;
102
+ while (!cursor.done && /[0-9]/.test(cursor.peek())) cursor.at += 1;
103
+ const digits = cursor.text.slice(start, cursor.at);
104
+ // RFC 8941 section 3.3.1 caps an integer at fifteen digits, which is also what keeps it exact
105
+ // in a JavaScript number: a longer one would round, and a rounded `created` is a different one.
106
+ if (!/^-?[0-9]{1,15}$/.test(digits)) {
107
+ throw new MalformedSyntax(`Expected an integer of at most 15 digits at offset ${start} of ${cursor.text}.`);
108
+ }
109
+ return Number(digits);
110
+ }
111
+
112
+ function parseBareItem(cursor) {
113
+ const char = cursor.peek();
114
+ if (char === '"') return parseString(cursor);
115
+ if (char === ':') return parseByteSequence(cursor);
116
+ if (char === '?') {
117
+ cursor.take();
118
+ const flag = cursor.take();
119
+ if (flag !== '0' && flag !== '1') throw new MalformedSyntax('A boolean is ?0 or ?1.');
120
+ return flag === '1';
121
+ }
122
+ if (char === '-' || /[0-9]/.test(char ?? '')) return parseInteger(cursor);
123
+ throw new MalformedSyntax(`Unsupported item at offset ${cursor.at} of ${cursor.text}.`);
124
+ }
125
+
126
+ function parseParameters(cursor) {
127
+ const params = new Map();
128
+ while (!cursor.done && cursor.peek() === ';') {
129
+ cursor.take();
130
+ cursor.skipSpace();
131
+ const key = parseKey(cursor);
132
+ if (!cursor.done && cursor.peek() === '=') {
133
+ cursor.take();
134
+ params.set(key, parseBareItem(cursor));
135
+ } else {
136
+ params.set(key, true);
137
+ }
138
+ }
139
+ return params;
140
+ }
141
+
142
+ function parseInnerList(cursor) {
143
+ cursor.expect('(');
144
+ const items = [];
145
+ for (;;) {
146
+ cursor.skipSpace();
147
+ if (cursor.done) throw new MalformedSyntax('An inner list ran to the end of the field.');
148
+ if (cursor.peek() === ')') {
149
+ cursor.take();
150
+ break;
151
+ }
152
+ items.push({ value: parseBareItem(cursor), params: parseParameters(cursor) });
153
+ if (!cursor.done && cursor.peek() !== ' ' && cursor.peek() !== ')') {
154
+ throw new MalformedSyntax(`Expected a space or ")" at offset ${cursor.at}.`);
155
+ }
156
+ }
157
+ return { items, params: parseParameters(cursor) };
158
+ }
159
+
160
+ /** Parse one RFC 8941 item with its parameters, the whole of `text`: `"@path";req`. */
161
+ export function parseItem(text) {
162
+ const cursor = new Cursor(text);
163
+ cursor.skipSpace();
164
+ const item = { value: parseBareItem(cursor), params: parseParameters(cursor) };
165
+ cursor.skipSpace();
166
+ if (!cursor.done) throw new MalformedSyntax(`Unexpected text after the item at offset ${cursor.at} of ${text}.`);
167
+ return item;
168
+ }
169
+
170
+ /** Parse an RFC 8941 dictionary whose members are inner lists or byte sequences. */
171
+ export function parseDictionary(text) {
172
+ const cursor = new Cursor(text);
173
+ const out = new Map();
174
+ cursor.skipSpace();
175
+ while (!cursor.done) {
176
+ const key = parseKey(cursor);
177
+ let value;
178
+ if (!cursor.done && cursor.peek() === '=') {
179
+ cursor.take();
180
+ value =
181
+ cursor.peek() === '('
182
+ ? parseInnerList(cursor)
183
+ : { value: parseBareItem(cursor), params: parseParameters(cursor) };
184
+ } else {
185
+ value = { value: true, params: parseParameters(cursor) };
186
+ }
187
+ out.set(key, value);
188
+ cursor.skipSpace();
189
+ if (cursor.done) break;
190
+ cursor.expect(',');
191
+ cursor.skipSpace();
192
+ if (cursor.done) throw new MalformedSyntax('A dictionary ended with a trailing comma.');
193
+ }
194
+ return out;
195
+ }
196
+
197
+ const serializeBareItem = (value) => {
198
+ if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
199
+ if (typeof value === 'number') return String(value);
200
+ // Only `false` reaches here: RFC 8941 renders a true-valued parameter as a bare key, which
201
+ // serializeParameters does before calling this, and fiki never puts a boolean in an item
202
+ // position. A `?1` arm would be unreachable.
203
+ if (value === false) return '?0';
204
+ return `:${toBase64(value)}:`;
205
+ };
206
+
207
+ const serializeParameters = (params) =>
208
+ [...params].map(([key, value]) => (value === true ? `;${key}` : `;${key}=${serializeBareItem(value)}`)).join('');
209
+
210
+ /** Serialize one item with its parameters: `{value: '@path', params: {req: true}}` is `"@path";req`. */
211
+ export const serializeItem = ({ value, params }) => `${serializeBareItem(value)}${serializeParameters(params)}`;
212
+
213
+ /** Serialize an inner list of covered components with its signature parameters. */
214
+ export function serializeInnerList({ items, params }) {
215
+ return `(${items.map(serializeItem).join(' ')})${serializeParameters(params)}`;
216
+ }
217
+
218
+ /** Serialize a byte sequence as a dictionary member, which is how RFC 9421 carries a signature. */
219
+ export const serializeByteSequence = (bytes) => serializeBareItem(bytes);