-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Expand file tree
/
Copy pathini.ts
More file actions
274 lines (247 loc) · 11.1 KB
/
Copy pathini.ts
File metadata and controls
274 lines (247 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
/**
* A minimal INI reader/writer for the AWS-style `~/.sim/config` and
* `~/.sim/credentials` files.
*
* Parsing keeps every line it did not understand — comments, blank lines,
* unrecognized keys — and writing re-emits them in place. These are files people
* hand-edit, so a round trip through `sim login` must not silently delete the
* comment above someone's staging endpoint.
*
* Deliberately not a general INI implementation: no nested sections, no `[a.b]`
* paths, no quoting rules beyond trimming. The format only has to carry a
* handful of flat string settings.
*/
/**
* An invalid stored setting, or a value the config files cannot represent.
*
* Defined here rather than in `profile.ts` because the writer below is the
* lowest layer that rejects input, and `profile.ts` already imports this module.
* `profile.ts` re-exports it, so callers keep seeing one error type — the one
* the entrypoint renders as a message instead of a stack trace.
*/
export class ProfileConfigError extends Error {
constructor(message: string) {
super(message)
this.name = 'ProfileConfigError'
}
}
type Entry = { kind: 'kv'; key: string; value: string } | { kind: 'raw'; text: string }
interface Section {
name: string
/**
* The header line exactly as it was read, re-emitted verbatim.
*
* {@link parseIni} trims the bracketed text to get {@link name}, so writing
* `[${name}]` back rewrote the header of every section in the file — a
* `configure --set-output` on `default` silently reformatted a hand-written
* `[profile padded ]` it was never asked to touch. Absent only on a
* section {@link setSectionValues} created, which has no original line.
*/
header?: string
entries: Entry[]
}
export interface IniDocument {
/** Lines before the first section header. */
preamble: string[]
sections: Section[]
}
const SECTION_PATTERN = /^\s*\[([^\]]*)\]\s*$/
const KV_PATTERN = /^\s*([A-Za-z0-9_.-]+)\s*=\s*(.*?)\s*$/
/** The character-class body both forbidden-character patterns are built from. */
const FORBIDDEN_CLASS = '\\u0000-\\u001f\\u007f-\\u009f\\u2028\\u2029'
/**
* Characters a stored value may not contain.
*
* The format has no escape syntax (see the module note), so text that can end a
* line is structure, not data: a value carrying a line break was written
* verbatim and read back on the next load as a *second* setting in the same
* section. That is the class of bug this closes — untrusted text reaching the
* serializer could add settings nobody typed.
*
* The set is every C0 and C1 control character plus U+2028 and U+2029, the two
* Unicode line separators. The separators matter for a second reason: they are
* not line breaks to the reader below, so {@link KV_PATTERN} (whose `.` never
* matches them) fails and the line is kept as opaque `raw` text — the key
* silently vanishes on the next read although the write reported success, and
* because the dead line no longer matches the key, the write after that appends
* a duplicate.
*
* Exported because callers that refuse untrusted text *before* writing — so they
* can say which side produced it — have to refuse exactly this set. A second
* hand-kept copy drifted from this one once already, and the gap let a rejected
* write land after an accepted one.
*/
export const FORBIDDEN_IN_VALUE = new RegExp(`[${FORBIDDEN_CLASS}]`)
/** As {@link FORBIDDEN_IN_VALUE}, plus the brackets that would close or open a header. */
const FORBIDDEN_IN_NAME = new RegExp(`[${FORBIDDEN_CLASS}[\\]]`)
/** Keys have to round-trip through the reader's own key pattern. */
const WRITABLE_KEY = /^[A-Za-z0-9_.-]+$/
/**
* Refuses text that would not survive a write-then-read cycle as the same value.
*
* Rejecting rather than escaping is deliberate: the format has no escape
* syntax, these files are hand-edited and read by AWS-style tooling that would
* not decode one, and no legitimate profile name, workspace id, endpoint, or
* API key contains a line break or a bracket. Inventing an escape here would
* make the files unreadable to everything else that opens them.
*/
function assertWritable(text: string, what: string, forbidden: RegExp): void {
if (forbidden.test(text)) {
throw new ProfileConfigError(
`Refusing to write ${what}: line breaks and control characters cannot be stored in the ~/.sim files, because the format has no way to escape them.`
)
}
// The reader trims both section names and values, so padded text never comes
// back as written. For a section name that also corrupts the file: the block
// is written under the padded name but read under the trimmed one, so the
// next write finds no match and appends a second block. For a value it is
// quieter but no more acceptable — the key reads back trimmed, so a padded
// secret is silently stored as a different secret than the caller passed.
if (text !== text.trim()) {
throw new ProfileConfigError(
`Refusing to write ${what}: leading or trailing whitespace is not preserved by the ~/.sim files, so it would not read back as written.`
)
}
}
export function parseIni(text: string): IniDocument {
const doc: IniDocument = { preamble: [], sections: [] }
let current: Section | null = null
const lines = text.split('\n')
// The file's terminating newline splits into a trailing empty element. Kept,
// it became a blank `raw` entry owned by the last section, and
// `setSectionValues` appended every later key after it — so each successive
// write opened another gap between that section's keys.
if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop()
for (const line of lines) {
const sectionMatch = SECTION_PATTERN.exec(line)
if (sectionMatch) {
current = { name: sectionMatch[1].trim(), header: line, entries: [] }
doc.sections.push(current)
continue
}
if (!current) {
doc.preamble.push(line)
continue
}
const kvMatch = KV_PATTERN.exec(line)
// A `#`/`;` comment can contain `=`, so the comment check must come first.
if (kvMatch && !/^\s*[#;]/.test(line)) {
current.entries.push({ kind: 'kv', key: kvMatch[1], value: kvMatch[2] })
} else {
current.entries.push({ kind: 'raw', text: line })
}
}
return doc
}
export function serializeIni(doc: IniDocument): string {
const lines: string[] = [...doc.preamble]
for (const section of doc.sections) {
// Keep exactly one blank line between sections without accumulating them
// across repeated writes.
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
if (lines.length > 0) lines.push('')
lines.push(section.header ?? `[${section.name}]`)
for (const entry of section.entries) {
lines.push(entry.kind === 'kv' ? `${entry.key} = ${entry.value}` : entry.text)
}
}
while (lines.length > 0 && lines[lines.length - 1].trim() === '') lines.pop()
return lines.length > 0 ? `${lines.join('\n')}\n` : ''
}
/**
* Reads a section's keys, merging every block that repeats its name.
*
* A hand-edited file can carry two `[default]` blocks; reading only the first
* silently dropped the second's keys and reported them as unset. The merge is
* **first wins** per key, matching {@link setSectionValues}, which upserts into
* the first block — so a value written through this module is the one read back.
*/
export function getSection(doc: IniDocument, name: string): Record<string, string> | null {
const sections = doc.sections.filter((s) => s.name === name)
if (sections.length === 0) return null
const values: Record<string, string> = {}
for (const section of sections) {
for (const entry of section.entries) {
if (entry.kind === 'kv' && !Object.hasOwn(values, entry.key)) values[entry.key] = entry.value
}
}
return values
}
export function listSections(doc: IniDocument): string[] {
return doc.sections.map((s) => s.name)
}
/**
* Upserts values into a section, creating it when absent. A `null` value removes
* the key. Existing keys are updated where they sit so surrounding comments keep
* describing the line they were written above.
*
* A write targets the first block of that name, matching the first-wins read in
* {@link getSection}. A removal instead has to clear every block and every
* repeat of the key within one: deleting only the first left a later duplicate
* to win the merged read, so `--unset` reported success while the value stayed
* in force. A removal against a section that is not there writes nothing at all.
*
* This is the one place untrusted text enters the document, so it is where the
* write is refused: see {@link FORBIDDEN_IN_VALUE} for what cannot be stored
* and why the answer is a refusal rather than an escape.
*/
export function setSectionValues(
doc: IniDocument,
name: string,
values: Record<string, string | null>
): void {
// Everything is checked before anything is written, so a rejected write
// leaves the document exactly as it was rather than half-applied.
assertWritable(name, `a section named "${name}"`, FORBIDDEN_IN_NAME)
for (const [key, value] of Object.entries(values)) {
if (!WRITABLE_KEY.test(key)) {
throw new ProfileConfigError(`Refusing to write an unreadable setting name "${key}".`)
}
if (value === null) continue
// A value that is only whitespace reads back as the empty string, so the
// key would look stored and resolve as unset. Checked before the general
// rule below so it keeps its own, more specific message.
if (value.trim() === '') {
throw new ProfileConfigError(`Refusing to write a blank value for "${key}".`)
}
assertWritable(value, `a value for "${key}"`, FORBIDDEN_IN_VALUE)
}
const matching = doc.sections.filter((s) => s.name === name)
let section = matching[0]
if (!section) {
// A removal has nothing to create the section for, and an empty section is
// not inert: `listProfiles` counts section names, so conjuring one made an
// unknown profile permanently pass the "does this profile exist?" check.
if (Object.values(values).every((value) => value === null)) return
section = { name, entries: [] }
doc.sections.push(section)
matching.push(section)
}
for (const [key, value] of Object.entries(values)) {
if (value === null) {
for (const target of matching) {
target.entries = target.entries.filter((e) => !(e.kind === 'kv' && e.key === key))
}
continue
}
const index = section.entries.findIndex((e) => e.kind === 'kv' && e.key === key)
if (index === -1) {
section.entries.push({ kind: 'kv', key, value })
} else {
section.entries[index] = { kind: 'kv', key, value }
}
}
}
/**
* Drops every block carrying the name, and reports whether one was there.
*
* A hand-edited file can repeat a section, and {@link getSection} merges them
* all — so removing only the first left `sim logout` reporting a profile gone
* while its later block still answered every read.
*/
export function removeSection(doc: IniDocument, name: string): boolean {
const remaining = doc.sections.filter((s) => s.name !== name)
if (remaining.length === doc.sections.length) return false
doc.sections = remaining
return true
}