From 2b19177fb642724fbac29d7fed4be661e168dc71 Mon Sep 17 00:00:00 2001 From: dostrikov Date: Wed, 14 Aug 2024 13:22:01 +0300 Subject: [PATCH] fix(openapi-sampler): able to sample pattern with nested infinite quantifiers and max-length (#247) closes #246 --- .../src/samplers/StringSampler.ts | 92 +++++++++++++++---- packages/openapi-sampler/tests/string.spec.ts | 56 +++++++++++ 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index 8528b723..a17a1a31 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -2,6 +2,10 @@ import { Sampler, OpenAPISchema } from './Sampler'; import RandExp from 'randexp'; export class StringSampler implements Sampler { + // ADHOC: 500 seems enough to protect sampler in case of patterns with infinite + // quantifiers as the complexity may reach O(n^x) for the x quantifier nesting + private readonly MAX_PATTERN_SAMPLE_LENGTH = 500; + private readonly stringFormats = { 'email': () => 'jon.snow@targaryen.com', 'idn-email': () => 'джон.сноу@таргариен.укр', @@ -44,7 +48,7 @@ export class StringSampler implements Sampler { const { minLength: min, maxLength: max } = schema; - return this.checkLength(sampler(min || 0, max, schema), format, min, max); + return this.checkLength(sampler(min, max, schema), format, min, max); } private patternSample( @@ -52,33 +56,65 @@ export class StringSampler implements Sampler { min?: number, max?: number ): string { + this.assertLength(min, max); + const randExp = new RandExp(pattern); + randExp.randInt = (a, b) => Math.floor((a + b) / 2); - if (min) { - // ADHOC: make a probe for regex using min quantifier value - // e.g. ^[a]+[b]+$ expect 'ab', ^[a-z]*$ expect '' + if (min !== undefined) { + return this.sampleWithMinLength(randExp, min, max); + } - randExp.max = 0; - randExp.randInt = (a, _) => a; + randExp.max = max ?? randExp.max; + const result = randExp.gen(); - const result = randExp.gen(); + return max !== undefined && + result.length > max && + this.hasInfiniteQuantifier(pattern) + ? this.sampleWithMaxLength(randExp, max) + : result; + } - if (result.length >= min) { - return result; - } + private hasInfiniteQuantifier(pattern: string | RegExp) { + const pat = typeof pattern === 'string' ? pattern : pattern.source; - // ADHOC: fallback for failed min quantifier probe with doubled min length + return /(\+|\*|\{\d*,\})/.test(pat); + } - randExp.max = 2 * min; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); + private sampleWithMaxLength(randExp: RandExp, max: number): string { + let result = ''; - return this.adjustMaxLength(randExp.gen(), max); + for (let i = 1; i <= Math.min(max, 20); i++) { + randExp.max = Math.floor(max / i); + result = randExp.gen(); + + if (result.length <= max) { + break; + } } - randExp.max = max ?? randExp.max; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); + return result; + } + + private sampleWithMinLength(randExp: RandExp, min: number, max?: number) { + // ADHOC: make a probe for regex using min quantifier value + // e.g. ^[a]+[b]+$ expect 'ab', ^[a-z]*$ expect '' - return randExp.gen(); + const randInt = randExp.randInt; + randExp.max = min; + randExp.randInt = (a, _) => a; + + let result = randExp.gen(); + + randExp.randInt = randInt; + + if (result.length < min) { + // ADHOC: fallback for failed min quantifier probe with doubled min length + randExp.max = 2 * min; + result = this.adjustMaxLength(randExp.gen(), max); + } + + return result; } private checkLength( @@ -107,6 +143,28 @@ export class StringSampler implements Sampler { return value; } + private assertLength(min?: number, max?: number) { + const pairs = [ + { key: 'minLength', value: min }, + { key: 'maxLength', value: max } + ]; + + const boundariesStr = pairs + .filter((p) => !this.checkBoundary(p.value)) + .map((p) => `${p.key}=${p.value}`) + .join(', '); + + if (boundariesStr) { + throw new Error( + `Sample string cannot be generated by boundaries: ${boundariesStr}. Both minLength and maxLength must not exceed ${this.MAX_PATTERN_SAMPLE_LENGTH}` + ); + } + } + + private checkBoundary(boundary?: number) { + return boundary === undefined || boundary <= this.MAX_PATTERN_SAMPLE_LENGTH; + } + private adjustLength(sample: string, min: number, max: number): string { const minLength = min ? min : 0; const maxLength = max ? max : sample.length; diff --git a/packages/openapi-sampler/tests/string.spec.ts b/packages/openapi-sampler/tests/string.spec.ts index 12d937ac..0ccad8bb 100644 --- a/packages/openapi-sampler/tests/string.spec.ts +++ b/packages/openapi-sampler/tests/string.spec.ts @@ -2,6 +2,16 @@ import { sample } from '../src'; describe('StringSampler', () => { [ + { + input: { + maxLength: 55, + minLength: 0, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: 'A@a.a' + }, { input: { type: 'string', @@ -410,6 +420,36 @@ describe('StringSampler', () => { }, expected: 'Sample string cannot be generated by boundaries: maxLength=35, format=uuid' + }, + { + input: { + maxLength: Number.MAX_SAFE_INTEGER, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: + 'Sample string cannot be generated by boundaries: maxLength=9007199254740991. Both minLength and maxLength must not exceed 500' + }, + { + input: { + minLength: Number.MAX_SAFE_INTEGER, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: + 'Sample string cannot be generated by boundaries: minLength=9007199254740991. Both minLength and maxLength must not exceed 500' + }, + { + input: { + maxLength: 501, + format: 'pattern', + pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', + type: 'string' + }, + expected: + 'Sample string cannot be generated by boundaries: maxLength=501. Both minLength and maxLength must not exceed 500' } ].forEach(({ input, expected }) => { const { type, ...restrictions } = input; @@ -421,4 +461,20 @@ describe('StringSampler', () => { expect(result).toThrowError(expected); }); }); + + it.each([10, 100, 500])(`should handle maxLength=%d gracefully`, (input) => { + // arrange + const pattern = /^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{1,4}$/; + + // act + const result = sample({ + maxLength: input, + format: 'pattern', + pattern: pattern.source, + type: 'string' + }); + + // assert + expect(result).toMatch(pattern); + }); });