toolcraft-openapi 0.0.49 → 0.0.51

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.
@@ -34,6 +34,10 @@ export async function mockFetch(options) {
34
34
  body: parsedBody,
35
35
  at: new Date()
36
36
  });
37
+ const parameterErrors = validateRequestParameters(operation, requestUrl, headers, document);
38
+ if (parameterErrors.length > 0) {
39
+ return jsonResponse(422, { errors: parameterErrors });
40
+ }
37
41
  if (operation.requestBodySchema !== undefined && parsedBody !== undefined) {
38
42
  const errors = validateAgainstSchema(parsedBody, operation.requestBodySchema, document, "$");
39
43
  if (errors.length > 0) {
@@ -46,7 +50,7 @@ export async function mockFetch(options) {
46
50
  const fixture = await fixtureLoader(operation.operationId);
47
51
  if (fixture !== undefined) {
48
52
  const status = fixture.status ?? operation.defaultStatus;
49
- const responseSchema = operation.responseSchemas.get(status);
53
+ const responseSchema = findResponseSchema(operation.responseSchemas, status);
50
54
  if (responseSchema !== undefined && fixture.body !== undefined) {
51
55
  const errors = validateAgainstSchema(fixture.body, responseSchema, document, "$response");
52
56
  if (errors.length > 0) {
@@ -110,13 +114,16 @@ function compileOperations(document) {
110
114
  const operationId = resolvedOperation.operationId ?? `${method.toUpperCase()} ${pathTemplate}`;
111
115
  const { defaultStatus, defaultExample, responseSchemas } = pickResponseMetadata(resolvedOperation, document);
112
116
  const { schema: requestBodySchema, required: requestBodyRequired } = pickRequestBody(resolvedOperation, document);
117
+ const pathParameterNames = collectPathParameterNames(pathTemplate);
113
118
  compiled.push({
114
119
  method: method.toUpperCase(),
115
120
  pathTemplate,
116
121
  pathRegex: pathTemplateToRegex(pathTemplate),
122
+ pathParameterNames,
117
123
  pathSpecificity: countPathPlaceholders(pathTemplate),
118
124
  operationId,
119
125
  operation: resolvedOperation,
126
+ parameters: collectCompiledParameters(pathItem.parameters, resolvedOperation.parameters, document),
120
127
  defaultStatus,
121
128
  defaultExample,
122
129
  requestBodySchema,
@@ -131,14 +138,31 @@ function compileOperations(document) {
131
138
  return compiled;
132
139
  }
133
140
  function countPathPlaceholders(template) {
134
- return (template.match(/\{[^}]+\}/g) ?? []).length;
141
+ return collectPathParameterNames(template).length;
135
142
  }
136
143
  function pathTemplateToRegex(template) {
137
144
  // Escape regex metacharacters except for "{...}" placeholders, which become non-slash captures.
138
145
  const pattern = template.replace(/[.*+?^${}()|[\]\\]/g, (match) => match === "{" || match === "}" ? match : `\\${match}`);
139
- const withParams = pattern.replace(/\{[^}]+\}/g, "[^/]+");
146
+ const withParams = pattern.replace(/\{[^}]+\}/g, "([^/]+)");
140
147
  return new RegExp(`^${withParams}$`);
141
148
  }
149
+ function collectPathParameterNames(template) {
150
+ const names = [];
151
+ let index = 0;
152
+ while (index < template.length) {
153
+ const start = template.indexOf("{", index);
154
+ if (start === -1) {
155
+ break;
156
+ }
157
+ const end = template.indexOf("}", start + 1);
158
+ if (end === -1) {
159
+ break;
160
+ }
161
+ names.push(template.slice(start + 1, end));
162
+ index = end + 1;
163
+ }
164
+ return names;
165
+ }
142
166
  function resolveOperation(operation, document) {
143
167
  if (isReference(operation)) {
144
168
  return resolveReference(operation, document);
@@ -147,10 +171,11 @@ function resolveOperation(operation, document) {
147
171
  }
148
172
  function pickResponseMetadata(operation, document) {
149
173
  const responses = operation.responses ?? {};
150
- const responseSchemas = new Map();
174
+ const responseSchemas = [];
175
+ const successResponses = [];
151
176
  for (const [code, response] of Object.entries(responses)) {
152
- const status = parseInt(code, 10);
153
- if (!Number.isFinite(status) || response === undefined) {
177
+ const status = parseResponseStatus(code);
178
+ if (status === undefined || response === undefined) {
154
179
  continue;
155
180
  }
156
181
  const resolved = isReference(response)
@@ -158,27 +183,46 @@ function pickResponseMetadata(operation, document) {
158
183
  : response;
159
184
  const schema = extractResponseSchema(resolved, document);
160
185
  if (schema !== undefined) {
161
- responseSchemas.set(status, schema);
186
+ responseSchemas.push({ ...status, schema });
187
+ }
188
+ if (isSuccessStatus(status.status)) {
189
+ successResponses.push({ status: status.status, response });
162
190
  }
163
191
  }
164
- const successCodes = Object.keys(responses)
165
- .map((code) => parseInt(code, 10))
166
- .filter((code) => Number.isFinite(code) && code >= 200 && code < 300)
167
- .sort((a, b) => a - b);
168
- if (successCodes.length === 0) {
192
+ successResponses.sort((a, b) => a.status - b.status);
193
+ const firstSuccess = successResponses[0];
194
+ if (firstSuccess === undefined) {
169
195
  return { defaultStatus: 200, defaultExample: undefined, responseSchemas };
170
196
  }
171
- const status = successCodes[0];
172
- const response = responses[String(status)];
173
- const resolvedResponse = response !== undefined && isReference(response)
174
- ? resolveReference(response, document)
175
- : response;
197
+ const resolvedResponse = isReference(firstSuccess.response)
198
+ ? resolveReference(firstSuccess.response, document)
199
+ : firstSuccess.response;
176
200
  return {
177
- defaultStatus: status,
201
+ defaultStatus: firstSuccess.status,
178
202
  defaultExample: extractExample(resolvedResponse, document),
179
203
  responseSchemas
180
204
  };
181
205
  }
206
+ function parseResponseStatus(code) {
207
+ if (code.length === 3 && code[1]?.toUpperCase() === "X" && code[2]?.toUpperCase() === "X") {
208
+ const family = Number(code[0]);
209
+ return Number.isInteger(family) && family >= 1 && family <= 5
210
+ ? { status: family * 100, range: true }
211
+ : undefined;
212
+ }
213
+ if (code.length !== 3 || [...code].some((character) => character < "0" || character > "9")) {
214
+ return undefined;
215
+ }
216
+ return { status: Number(code), range: false };
217
+ }
218
+ function isSuccessStatus(status) {
219
+ return status >= 200 && status < 300;
220
+ }
221
+ function findResponseSchema(schemas, status) {
222
+ return (schemas.find((candidate) => !candidate.range && candidate.status === status)?.schema ??
223
+ schemas.find((candidate) => candidate.range && Math.floor(status / 100) === candidate.status / 100)
224
+ ?.schema);
225
+ }
182
226
  function extractResponseSchema(response, document) {
183
227
  if (response === undefined) {
184
228
  return undefined;
@@ -211,6 +255,95 @@ function pickRequestBody(operation, document) {
211
255
  : media.schema;
212
256
  return { schema, required: resolved.required === true };
213
257
  }
258
+ function collectCompiledParameters(pathParameters, operationParameters, document) {
259
+ const parameters = new Map();
260
+ for (const raw of [...(pathParameters ?? []), ...(operationParameters ?? [])]) {
261
+ const parameter = isReference(raw)
262
+ ? resolveReference(raw, document)
263
+ : raw;
264
+ if (parameter.in !== "path" && parameter.in !== "query" && parameter.in !== "header") {
265
+ continue;
266
+ }
267
+ parameters.set(`${parameter.in}:${parameter.name}`, {
268
+ name: parameter.name,
269
+ location: parameter.in,
270
+ required: parameter.in === "path" || parameter.required === true,
271
+ schema: parameter.schema
272
+ });
273
+ }
274
+ return [...parameters.values()];
275
+ }
276
+ function validateRequestParameters(operation, requestUrl, headers, document) {
277
+ const pathValues = collectPathParameterValues(operation, requestUrl.pathname);
278
+ const errors = [];
279
+ for (const parameter of operation.parameters) {
280
+ const pointer = `$${parameter.location}/${parameter.name}`;
281
+ const value = readParameterValue(parameter, requestUrl, headers, pathValues);
282
+ if (value === undefined) {
283
+ if (parameter.required) {
284
+ errors.push(`${pointer}: required`);
285
+ }
286
+ continue;
287
+ }
288
+ if (parameter.schema !== undefined) {
289
+ errors.push(...validateAgainstSchema(value, parameter.schema, document, pointer));
290
+ }
291
+ }
292
+ return errors;
293
+ }
294
+ function collectPathParameterValues(operation, pathname) {
295
+ const match = operation.pathRegex.exec(pathname);
296
+ const values = new Map();
297
+ if (match === null) {
298
+ return values;
299
+ }
300
+ for (let index = 0; index < operation.pathParameterNames.length; index++) {
301
+ const value = match[index + 1];
302
+ if (value !== undefined) {
303
+ values.set(operation.pathParameterNames[index], decodeUrlComponent(value));
304
+ }
305
+ }
306
+ return values;
307
+ }
308
+ function readParameterValue(parameter, requestUrl, headers, pathValues) {
309
+ switch (parameter.location) {
310
+ case "path":
311
+ return coerceParameterValue(parameter.schema, pathValues.get(parameter.name));
312
+ case "query": {
313
+ const values = requestUrl.searchParams.getAll(parameter.name);
314
+ if (values.length === 0) {
315
+ return undefined;
316
+ }
317
+ return schemaAcceptsArray(parameter.schema) ? coerceParameterValues(values) : values[0];
318
+ }
319
+ case "header":
320
+ return coerceParameterValue(parameter.schema, headers[parameter.name.toLowerCase()]);
321
+ }
322
+ }
323
+ function coerceParameterValue(schema, value) {
324
+ if (value === undefined || !schemaAcceptsArray(schema)) {
325
+ return value;
326
+ }
327
+ return coerceParameterValues([value]);
328
+ }
329
+ function coerceParameterValues(values) {
330
+ return values.flatMap((value) => value.split(","));
331
+ }
332
+ function schemaAcceptsArray(schema) {
333
+ if (schema === undefined || isReference(schema)) {
334
+ return false;
335
+ }
336
+ const type = schema.type;
337
+ return Array.isArray(type) ? type.includes("array") : type === "array";
338
+ }
339
+ function decodeUrlComponent(value) {
340
+ try {
341
+ return decodeURIComponent(value);
342
+ }
343
+ catch {
344
+ return value;
345
+ }
346
+ }
214
347
  function extractExample(response, document) {
215
348
  if (response === undefined) {
216
349
  return undefined;
@@ -265,7 +398,7 @@ function resolveReference(reference, document) {
265
398
  const segments = ref
266
399
  .slice(2)
267
400
  .split("/")
268
- .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~"));
401
+ .map(decodeReferenceSegment);
269
402
  let current = document;
270
403
  for (const segment of segments) {
271
404
  if (current === null || typeof current !== "object") {
@@ -281,6 +414,16 @@ function resolveReference(reference, document) {
281
414
  }
282
415
  return current;
283
416
  }
417
+ function decodeReferenceSegment(segment) {
418
+ let decoded = segment;
419
+ try {
420
+ decoded = decodeURIComponent(segment);
421
+ }
422
+ catch {
423
+ decoded = segment;
424
+ }
425
+ return decoded.replace(/~1/g, "/").replace(/~0/g, "~");
426
+ }
284
427
  function validateAgainstSchema(value, schema, document, pointer) {
285
428
  const resolved = isReference(schema)
286
429
  ? resolveReference(schema, document)
@@ -334,11 +477,36 @@ function validateAgainstSchema(value, schema, document, pointer) {
334
477
  errors.push(...validateAgainstSchema(value[i], resolved.items, document, `${pointer}/${i}`));
335
478
  }
336
479
  }
480
+ if (typeof value === "number") {
481
+ validateNumberConstraints(value, resolved, pointer, errors);
482
+ }
483
+ if (typeof value === "string") {
484
+ validateStringConstraints(value, resolved, pointer, errors);
485
+ }
337
486
  if (resolved.enum !== undefined && !resolved.enum.includes(value)) {
338
487
  errors.push(`${pointer}: not in enum`);
339
488
  }
340
489
  return errors;
341
490
  }
491
+ function validateNumberConstraints(value, schema, pointer, errors) {
492
+ if (schema.minimum !== undefined && value < schema.minimum) {
493
+ errors.push(`${pointer}: expected value to be >= ${schema.minimum}`);
494
+ }
495
+ if (schema.maximum !== undefined && value > schema.maximum) {
496
+ errors.push(`${pointer}: expected value to be <= ${schema.maximum}`);
497
+ }
498
+ }
499
+ function validateStringConstraints(value, schema, pointer, errors) {
500
+ if (schema.minLength !== undefined && value.length < schema.minLength) {
501
+ errors.push(`${pointer}: expected length to be >= ${schema.minLength}`);
502
+ }
503
+ if (schema.maxLength !== undefined && value.length > schema.maxLength) {
504
+ errors.push(`${pointer}: expected length to be <= ${schema.maxLength}`);
505
+ }
506
+ if (schema.pattern !== undefined && !new RegExp(schema.pattern).test(value)) {
507
+ errors.push(`${pointer}: expected value to match pattern ${schema.pattern}`);
508
+ }
509
+ }
342
510
  function normalizeTypes(schema) {
343
511
  const type = schema.type;
344
512
  if (type === undefined) {
@@ -1,8 +1,25 @@
1
1
  export type FrontmatterBlock = {
2
2
  raw: string;
3
3
  body: string;
4
+ rawStart: number;
5
+ rawEnd: number;
4
6
  };
5
7
  export type SplitFrontmatterResult = FrontmatterBlock | {
6
8
  body: string;
7
9
  };
10
+ export type InspectFrontmatterResult = (FrontmatterBlock & {
11
+ kind: "frontmatter";
12
+ }) | {
13
+ kind: "missing-closing-fence";
14
+ raw: string;
15
+ rawStart: number;
16
+ rawEnd: number;
17
+ body: "";
18
+ message: string;
19
+ position: number;
20
+ } | {
21
+ kind: "body";
22
+ body: string;
23
+ };
8
24
  export declare function splitFrontmatterBlock(source: string): SplitFrontmatterResult;
25
+ export declare function inspectFrontmatterBlock(source: string): InspectFrontmatterResult;
@@ -1,15 +1,43 @@
1
+ const MISSING_END_DELIMITER_MESSAGE = "Missing YAML frontmatter end delimiter (---).";
1
2
  export function splitFrontmatterBlock(source) {
3
+ const inspected = inspectFrontmatterBlock(source);
4
+ if (inspected.kind === "body") {
5
+ return { body: inspected.body };
6
+ }
7
+ if (inspected.kind === "missing-closing-fence") {
8
+ throw new Error(inspected.message);
9
+ }
10
+ return {
11
+ raw: inspected.raw,
12
+ rawStart: inspected.rawStart,
13
+ rawEnd: inspected.rawEnd,
14
+ body: inspected.body
15
+ };
16
+ }
17
+ export function inspectFrontmatterBlock(source) {
2
18
  const content = source.startsWith("\uFEFF") ? source.slice(1) : source;
19
+ const sourceOffset = source.length - content.length;
3
20
  const opening = readOpeningFence(content);
4
21
  if (opening === undefined) {
5
- return { body: source };
22
+ return { kind: "body", body: source };
6
23
  }
7
24
  const closing = findClosingFence(content, opening.next);
8
25
  if (closing === undefined) {
9
- throw new Error("Missing YAML frontmatter end delimiter (---).");
26
+ return {
27
+ kind: "missing-closing-fence",
28
+ raw: content.slice(opening.next),
29
+ rawStart: sourceOffset + opening.next,
30
+ rawEnd: source.length,
31
+ body: "",
32
+ message: MISSING_END_DELIMITER_MESSAGE,
33
+ position: source.length
34
+ };
10
35
  }
11
36
  return {
37
+ kind: "frontmatter",
12
38
  raw: content.slice(opening.next, closing.index),
39
+ rawStart: sourceOffset + opening.next,
40
+ rawEnd: sourceOffset + closing.index,
13
41
  body: content.slice(closing.end + closing.lineBreakLength)
14
42
  };
15
43
  }
@@ -18,7 +46,7 @@ function readOpeningFence(source) {
18
46
  return undefined;
19
47
  }
20
48
  const lineEnd = findLineEnd(source, 0);
21
- if (lineEnd.lineBreakLength === 0 || source.slice(0, lineEnd.index) !== "---") {
49
+ if (lineEnd.lineBreakLength === 0 || !isFenceLine(source.slice(0, lineEnd.index))) {
22
50
  return undefined;
23
51
  }
24
52
  return { next: lineEnd.index + lineEnd.lineBreakLength };
@@ -27,7 +55,7 @@ function findClosingFence(source, start) {
27
55
  let lineStart = start;
28
56
  while (lineStart <= source.length) {
29
57
  const lineEnd = findLineEnd(source, lineStart);
30
- if (isClosingFenceLine(source.slice(lineStart, lineEnd.index))) {
58
+ if (isFenceLine(source.slice(lineStart, lineEnd.index))) {
31
59
  return {
32
60
  index: lineStart,
33
61
  end: lineEnd.index,
@@ -41,7 +69,7 @@ function findClosingFence(source, start) {
41
69
  }
42
70
  return undefined;
43
71
  }
44
- function isClosingFenceLine(line) {
72
+ function isFenceLine(line) {
45
73
  if (!line.startsWith("---")) {
46
74
  return false;
47
75
  }
@@ -1,5 +1,5 @@
1
1
  import { LineCounter, parse, parseDocument } from "yaml";
2
- import { splitFrontmatterBlock } from "./fences.js";
2
+ import { inspectFrontmatterBlock, splitFrontmatterBlock } from "./fences.js";
3
3
  export class FrontmatterParseError extends Error {
4
4
  constructor(message) {
5
5
  super(message);
@@ -20,9 +20,9 @@ export function parseFrontmatter(source, options = {}) {
20
20
  };
21
21
  }
22
22
  export function parseFrontmatterDocument(source, options = {}) {
23
- const split = splitFrontmatter(source);
24
- const lineCounter = new LineCounter();
25
- if (split.raw === undefined) {
23
+ const split = inspectFrontmatterBlock(source);
24
+ const lineCounter = createSourceLineCounter(source);
25
+ if (split.kind === "body") {
26
26
  return {
27
27
  frontmatter: {},
28
28
  body: split.body,
@@ -30,14 +30,26 @@ export function parseFrontmatterDocument(source, options = {}) {
30
30
  lineCounter
31
31
  };
32
32
  }
33
- const document = parseDocument(normalizeYamlLineEndings(split.raw), {
34
- lineCounter,
33
+ if (split.kind === "missing-closing-fence") {
34
+ return {
35
+ frontmatter: {},
36
+ body: split.body,
37
+ errors: [{ message: split.message, pos: [split.position, split.position] }],
38
+ lineCounter
39
+ };
40
+ }
41
+ const yamlLineCounter = new LineCounter();
42
+ const normalizedYaml = normalizeYamlLineEndings(split.raw);
43
+ const document = parseDocument(normalizedYaml, {
44
+ lineCounter: yamlLineCounter,
35
45
  prettyErrors: false,
36
46
  uniqueKeys: options.uniqueKeys ?? false
37
47
  });
38
48
  const errors = document.errors.map((error) => ({
39
49
  message: error.message,
40
- ...(error.pos === undefined ? {} : { pos: error.pos })
50
+ ...(error.pos === undefined
51
+ ? {}
52
+ : { pos: translateYamlErrorPosition(error.pos, split) })
41
53
  }));
42
54
  if (errors.length > 0) {
43
55
  return {
@@ -47,12 +59,72 @@ export function parseFrontmatterDocument(source, options = {}) {
47
59
  lineCounter
48
60
  };
49
61
  }
50
- return {
51
- frontmatter: normalizeYamlFrontmatter(document.toJSON()),
52
- body: split.body,
53
- errors,
54
- lineCounter
55
- };
62
+ try {
63
+ return {
64
+ frontmatter: normalizeYamlFrontmatter(document.toJSON()),
65
+ body: split.body,
66
+ errors,
67
+ lineCounter
68
+ };
69
+ }
70
+ catch (error) {
71
+ if (error instanceof FrontmatterParseError) {
72
+ return {
73
+ frontmatter: {},
74
+ body: split.body,
75
+ errors: [{ message: error.message }],
76
+ lineCounter
77
+ };
78
+ }
79
+ throw error;
80
+ }
81
+ }
82
+ function createSourceLineCounter(source) {
83
+ const lineCounter = new LineCounter();
84
+ lineCounter.addNewLine(0);
85
+ for (let index = 0; index < source.length; index += 1) {
86
+ const character = source[index];
87
+ if (character === "\n") {
88
+ lineCounter.addNewLine(index + 1);
89
+ continue;
90
+ }
91
+ if (character === "\r") {
92
+ lineCounter.addNewLine(index + (source[index + 1] === "\n" ? 2 : 1));
93
+ if (source[index + 1] === "\n") {
94
+ index += 1;
95
+ }
96
+ }
97
+ }
98
+ return lineCounter;
99
+ }
100
+ function translateYamlErrorPosition(pos, split) {
101
+ return pos.map((offset) => split.rawStart + normalizeYamlErrorOffset(split.raw, offset));
102
+ }
103
+ function normalizeYamlErrorOffset(raw, offset) {
104
+ if (offset >= raw.length && endsWithLineBreak(raw)) {
105
+ return findPreviousLineStart(raw, raw.length);
106
+ }
107
+ return offset;
108
+ }
109
+ function endsWithLineBreak(value) {
110
+ return value.endsWith("\n") || value.endsWith("\r");
111
+ }
112
+ function findPreviousLineStart(value, end) {
113
+ let index = end - 1;
114
+ if (value[index] === "\n") {
115
+ index -= 1;
116
+ }
117
+ if (value[index] === "\r") {
118
+ index -= 1;
119
+ }
120
+ while (index >= 0) {
121
+ const character = value[index];
122
+ if (character === "\n" || character === "\r") {
123
+ return index + 1;
124
+ }
125
+ index -= 1;
126
+ }
127
+ return 0;
56
128
  }
57
129
  function splitFrontmatter(source) {
58
130
  try {
@@ -102,7 +174,7 @@ function normalizeYamlFrontmatter(value) {
102
174
  if (value === null || value === undefined) {
103
175
  return {};
104
176
  }
105
- if (!isRecord(value)) {
177
+ if (!isPlainRecord(value)) {
106
178
  throw new FrontmatterParseError("YAML frontmatter must parse to an object.");
107
179
  }
108
180
  return normalizeYamlValue(value);
@@ -2,6 +2,7 @@ import { stringify } from "yaml";
2
2
  import { FrontmatterParseError } from "./parse.js";
3
3
  export function stringifyFrontmatter(frontmatter, body) {
4
4
  try {
5
+ assertFrontmatterRoot(frontmatter);
5
6
  assertAcyclic(frontmatter);
6
7
  return `---\n${stringify(frontmatter, { aliasDuplicateObjects: false }).trimEnd()}\n---\n${body}`;
7
8
  }
@@ -13,6 +14,11 @@ export function stringifyFrontmatter(frontmatter, body) {
13
14
  throw new FrontmatterParseError(`Invalid YAML frontmatter: ${message}`);
14
15
  }
15
16
  }
17
+ function assertFrontmatterRoot(value) {
18
+ if (!isPlainRecord(value)) {
19
+ throw new FrontmatterParseError("YAML frontmatter must parse to an object.");
20
+ }
21
+ }
16
22
  function assertAcyclic(value, seen = new WeakSet()) {
17
23
  if (typeof value !== "object" || value === null) {
18
24
  return;
@@ -33,3 +39,10 @@ function assertAcyclic(value, seen = new WeakSet()) {
33
39
  }
34
40
  seen.delete(value);
35
41
  }
42
+ function isPlainRecord(value) {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
44
+ return false;
45
+ }
46
+ const prototype = Object.getPrototypeOf(value);
47
+ return prototype === Object.prototype || prototype === null;
48
+ }
@@ -28,13 +28,14 @@ export function createSecretStore(input) {
28
28
  function resolveBackend(input) {
29
29
  const envVar = input.backendEnvVar ?? DEFAULT_BACKEND_ENV_VAR;
30
30
  const configuredBackend = input.backend ?? getOwnEnvValue(input.env, envVar) ?? getOwnEnvValue(process.env, envVar);
31
- if (configuredBackend === "keychain") {
31
+ const backend = configuredBackend?.trim();
32
+ if (backend === "keychain") {
32
33
  return "keychain";
33
34
  }
34
- if (configuredBackend === undefined || configuredBackend === "file") {
35
+ if (backend === undefined || backend === "file") {
35
36
  return "file";
36
37
  }
37
- throw new Error(`Unsupported auth store backend: ${configuredBackend}`);
38
+ throw new Error(`Unsupported auth store backend: ${backend}`);
38
39
  }
39
40
  function getOwnEnvValue(env, key) {
40
41
  return env !== undefined && Object.prototype.hasOwnProperty.call(env, key)
@@ -24,7 +24,10 @@ export class EncryptedFileStore {
24
24
  if (input.filePath === undefined) {
25
25
  const homeDirectory = (input.getHomeDirectory ?? homedir)();
26
26
  const defaultDirectory = input.defaultDirectory ?? ".auth-store";
27
- this.filePath = path.join(homeDirectory, defaultDirectory, input.defaultFileName ?? "credentials.enc");
27
+ const defaultFileName = input.defaultFileName ?? "credentials.enc";
28
+ assertSafeDefaultDirectory(defaultDirectory);
29
+ assertSafeDefaultFileName(defaultFileName);
30
+ this.filePath = path.join(homeDirectory, defaultDirectory, defaultFileName);
28
31
  this.symbolicLinkCheckStartPath = resolveDefaultDirectoryCheckStart(homeDirectory, defaultDirectory);
29
32
  }
30
33
  else {
@@ -154,7 +157,7 @@ function resolveDefaultDirectoryCheckStart(homeDirectory, defaultDirectory) {
154
157
  }
155
158
  function getProtectedCredentialPaths(resolvedPath, symbolicLinkCheckStartPath) {
156
159
  if (symbolicLinkCheckStartPath === null) {
157
- return [path.dirname(resolvedPath), resolvedPath];
160
+ return getExplicitProtectedCredentialPaths(resolvedPath);
158
161
  }
159
162
  const resolvedStartPath = path.resolve(symbolicLinkCheckStartPath);
160
163
  if (!isPathInsideOrEqual(resolvedPath, resolvedStartPath)) {
@@ -168,6 +171,50 @@ function getProtectedCredentialPaths(resolvedPath, symbolicLinkCheckStartPath) {
168
171
  }
169
172
  return protectedPaths;
170
173
  }
174
+ function assertSafeDefaultDirectory(defaultDirectory) {
175
+ if (path.isAbsolute(defaultDirectory) || path.win32.isAbsolute(defaultDirectory)) {
176
+ throw new Error("defaultDirectory must be a relative path inside the home directory");
177
+ }
178
+ for (const segment of splitPathSegments(defaultDirectory)) {
179
+ if (segment === "..") {
180
+ throw new Error("defaultDirectory must be a relative path inside the home directory");
181
+ }
182
+ }
183
+ }
184
+ function assertSafeDefaultFileName(defaultFileName) {
185
+ if (defaultFileName.trim().length === 0 ||
186
+ defaultFileName === "." ||
187
+ defaultFileName === ".." ||
188
+ splitPathSegments(defaultFileName).length !== 1) {
189
+ throw new Error("defaultFileName must be a file name without path separators");
190
+ }
191
+ }
192
+ function splitPathSegments(value) {
193
+ return value
194
+ .split("/")
195
+ .flatMap((segment) => segment.split("\\"))
196
+ .filter((segment) => segment.length > 0);
197
+ }
198
+ function getExplicitProtectedCredentialPaths(resolvedPath) {
199
+ const parsed = path.parse(resolvedPath);
200
+ const segments = resolvedPath
201
+ .slice(parsed.root.length)
202
+ .split(path.sep)
203
+ .filter((segment) => segment.length > 0);
204
+ if (segments.length <= 1) {
205
+ return [resolvedPath];
206
+ }
207
+ const protectedPaths = [];
208
+ let currentPath = parsed.root;
209
+ for (const [index, segment] of segments.entries()) {
210
+ currentPath = path.join(currentPath, segment);
211
+ if (index === 0) {
212
+ continue;
213
+ }
214
+ protectedPaths.push(currentPath);
215
+ }
216
+ return protectedPaths;
217
+ }
171
218
  function isPathInsideOrEqual(childPath, parentPath) {
172
219
  const relativePath = path.relative(parentPath, childPath);
173
220
  return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
@@ -7,8 +7,14 @@ export class KeychainStore {
7
7
  account;
8
8
  constructor(input) {
9
9
  this.runCommand = input.runCommand ?? runSecurityCommand;
10
- this.service = input.service;
11
- this.account = input.account;
10
+ this.service = input.service.trim();
11
+ this.account = input.account.trim();
12
+ if (this.service.length === 0) {
13
+ throw new Error("Keychain service must not be empty");
14
+ }
15
+ if (this.account.length === 0) {
16
+ throw new Error("Keychain account must not be empty");
17
+ }
12
18
  }
13
19
  async get() {
14
20
  const result = await this.executeSecurityCommand(["find-generic-password", "-s", this.service, "-a", this.account, "-w"], "read secret from macOS Keychain");
@@ -31,8 +37,9 @@ export class KeychainStore {
31
37
  "-a",
32
38
  this.account,
33
39
  "-U",
34
- "-w"
35
- ], "store secret in macOS Keychain", { stdin: value });
40
+ "-w",
41
+ value
42
+ ], "store secret in macOS Keychain");
36
43
  if (getCommandExitCode(result) !== 0) {
37
44
  throw createSecurityCliFailure("store secret in macOS Keychain", result);
38
45
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolcraft-openapi",
3
- "version": "0.0.49",
3
+ "version": "0.0.51",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -28,7 +28,7 @@
28
28
  "toolcraft-openapi-generate": "dist/bin/generate.js"
29
29
  },
30
30
  "dependencies": {
31
- "toolcraft": "0.0.49",
31
+ "toolcraft": "0.0.51",
32
32
  "auth-store": "^0.0.1",
33
33
  "yaml": "^2.8.2"
34
34
  },