From c610687081248b723c9eace96b9fd256e456751d Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Fri, 9 Aug 2024 10:19:20 +0300 Subject: [PATCH 1/5] fix(sampler): able to sample pattern format with nested infinite quantifiers and max-length constraint closes #246 --- .../src/samplers/StringSampler.ts | 53 +++++++++++++++---- packages/openapi-sampler/tests/string.spec.ts | 11 ++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index 8528b723..792ed981 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -55,30 +55,61 @@ export class StringSampler implements Sampler { const randExp = new RandExp(pattern); if (min) { - // ADHOC: make a probe for regex using min quantifier value - // e.g. ^[a]+[b]+$ expect 'ab', ^[a-z]*$ expect '' + return this.sampleMinLength(randExp, min, max); + } + + randExp.max = max ?? randExp.max; + randExp.randInt = (a, b) => Math.floor((a + b) / 2); + + const result = randExp.gen(); + + return !!max && result.length > max && this.hasInfiniteQuantifier(pattern) + ? this.sampleInfiniteQuantifier(randExp, max) + : result; + } + + private hasInfiniteQuantifier(pattern: string | RegExp) { + const pat = pattern.toString(); + + return ['+', '*', ',}'].some((q) => pat.includes(q)); + } - randExp.max = 0; - randExp.randInt = (a, _) => a; + private sampleInfiniteQuantifier(randExp: RandExp, max: number): string { + + randExp.randInt = (a, b) => Math.floor((a + b) / 2); + + for (let i = 1, lmax = max; lmax > 0; lmax = Math.floor(max / ++i)) { + randExp.max = lmax; const result = randExp.gen(); - if (result.length >= min) { + if (result.length <= max) { return result; } + } - // ADHOC: fallback for failed min quantifier probe with doubled min length + return ''; + } - randExp.max = 2 * min; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); + private sampleMinLength(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 this.adjustMaxLength(randExp.gen(), max); + randExp.max = 0; + randExp.randInt = (a, _) => a; + + const result = randExp.gen(); + + if (result.length >= min) { + return result; } - randExp.max = max ?? randExp.max; + // ADHOC: fallback for failed min quantifier probe with doubled min length + + randExp.max = 2 * min; randExp.randInt = (a, b) => Math.floor((a + b) / 2); - return randExp.gen(); + return this.adjustMaxLength(randExp.gen(), max); } private checkLength( diff --git a/packages/openapi-sampler/tests/string.spec.ts b/packages/openapi-sampler/tests/string.spec.ts index 12d937ac..7880a90d 100644 --- a/packages/openapi-sampler/tests/string.spec.ts +++ b/packages/openapi-sampler/tests/string.spec.ts @@ -1,7 +1,18 @@ 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: "gggggg@FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.zz" + }, { input: { type: 'string', From f7b751326164014f4059c6dafd19743a91097cda Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Fri, 9 Aug 2024 10:29:46 +0300 Subject: [PATCH 2/5] fix(sampler): fix formatting closes #246 --- packages/openapi-sampler/src/samplers/StringSampler.ts | 1 - packages/openapi-sampler/tests/string.spec.ts | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index 792ed981..24f6868f 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -75,7 +75,6 @@ export class StringSampler implements Sampler { } private sampleInfiniteQuantifier(randExp: RandExp, max: number): string { - randExp.randInt = (a, b) => Math.floor((a + b) / 2); for (let i = 1, lmax = max; lmax > 0; lmax = Math.floor(max / ++i)) { diff --git a/packages/openapi-sampler/tests/string.spec.ts b/packages/openapi-sampler/tests/string.spec.ts index 7880a90d..464c11b1 100644 --- a/packages/openapi-sampler/tests/string.spec.ts +++ b/packages/openapi-sampler/tests/string.spec.ts @@ -1,6 +1,5 @@ import { sample } from '../src'; - describe('StringSampler', () => { [ { @@ -11,7 +10,7 @@ describe('StringSampler', () => { pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', type: 'string' }, - expected: "gggggg@FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.zz" + expected: 'gggggg@FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.zz' }, { input: { From 3f9780029ba111114ec753eba9ca562dffec4686 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 13 Aug 2024 19:50:57 +0300 Subject: [PATCH 3/5] fix(openapi-sampler): address pr comments closes #246 --- .../src/samplers/StringSampler.ts | 84 ++++++++++++------- packages/openapi-sampler/tests/string.spec.ts | 56 ++++++++++++- 2 files changed, 111 insertions(+), 29 deletions(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index 24f6868f..bd6bacfc 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -2,6 +2,7 @@ import { Sampler, OpenAPISchema } from './Sampler'; import RandExp from 'randexp'; export class StringSampler implements Sampler { + private readonly MAX_PATTERN_SAMPLE_LENGTH = 500; private readonly stringFormats = { 'email': () => 'jon.snow@targaryen.com', 'idn-email': () => 'джон.сноу@таргариен.укр', @@ -44,7 +45,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,63 +53,63 @@ 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) { - return this.sampleMinLength(randExp, min, max); + if (min !== undefined) { + return this.sampleWithMinLength(randExp, min, max); } randExp.max = max ?? randExp.max; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); - const result = randExp.gen(); - return !!max && result.length > max && this.hasInfiniteQuantifier(pattern) - ? this.sampleInfiniteQuantifier(randExp, max) + return max !== undefined && result.length > max && this.hasInfiniteQuantifier(pattern) + ? this.sampleWithMaxLength(randExp, max) : result; } private hasInfiniteQuantifier(pattern: string | RegExp) { - const pat = pattern.toString(); + const pat = typeof pattern === 'string' ? pattern : pattern.source; - return ['+', '*', ',}'].some((q) => pat.includes(q)); + return /(\+|\*|\{\d*,\})/.test(pat); } - private sampleInfiniteQuantifier(randExp: RandExp, max: number): string { - randExp.randInt = (a, b) => Math.floor((a + b) / 2); - - for (let i = 1, lmax = max; lmax > 0; lmax = Math.floor(max / ++i)) { - randExp.max = lmax; + private sampleWithMaxLength(randExp: RandExp, max: number): string { + let result = ''; - const result = randExp.gen(); + for (let i = 1; i <= Math.min(max, 20); i++) { + randExp.max = Math.floor(max / i); + result = randExp.gen(); if (result.length <= max) { - return result; + break; } } - return ''; + return result; } - private sampleMinLength(randExp: RandExp, min: number, max: number) { + 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 '' - randExp.max = 0; + const randInt = randExp.randInt; + randExp.max = min; randExp.randInt = (a, _) => a; - const result = randExp.gen(); - - if (result.length >= min) { - return result; - } + let result = randExp.gen(); - // ADHOC: fallback for failed min quantifier probe with doubled min length + randExp.randInt = randInt; - randExp.max = 2 * min; - randExp.randInt = (a, b) => Math.floor((a + b) / 2); + 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 this.adjustMaxLength(randExp.gen(), max); + return result; } private checkLength( @@ -137,6 +138,33 @@ 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}` + ); + } + } + + private checkBoundary(boundary?: number) { + return ( + boundary === undefined || + (typeof boundary === 'number' && + boundary >= 0 && + 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 464c11b1..42c40ffa 100644 --- a/packages/openapi-sampler/tests/string.spec.ts +++ b/packages/openapi-sampler/tests/string.spec.ts @@ -10,7 +10,7 @@ describe('StringSampler', () => { pattern: '^[A-Za-z0-9._%-]+@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{1,4}$', type: 'string' }, - expected: 'gggggg@FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.FFFFFF.zz' + expected: 'A@a.a' }, { input: { @@ -420,6 +420,44 @@ 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' + }, + { + 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' + }, + { + 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' + }, + { + input: { + minLength: -1, + 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=-1' } ].forEach(({ input, expected }) => { const { type, ...restrictions } = input; @@ -431,4 +469,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); + }); }); From 3db8fdac50f435aa022bda87346b075c47e325ca Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Tue, 13 Aug 2024 19:53:30 +0300 Subject: [PATCH 4/5] fix(openapi-sampler): fix formatting closes #246 --- packages/openapi-sampler/src/samplers/StringSampler.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index bd6bacfc..f2f70e99 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -65,7 +65,9 @@ export class StringSampler implements Sampler { randExp.max = max ?? randExp.max; const result = randExp.gen(); - return max !== undefined && result.length > max && this.hasInfiniteQuantifier(pattern) + return max !== undefined && + result.length > max && + this.hasInfiniteQuantifier(pattern) ? this.sampleWithMaxLength(randExp, max) : result; } From d124319d64dcb693275917c20cd99e54c35fc784 Mon Sep 17 00:00:00 2001 From: Dmitry Ostrikov Date: Wed, 14 Aug 2024 11:56:03 +0300 Subject: [PATCH 5/5] fix(openapi-sampler): address pr comments closes #246 --- .../src/samplers/StringSampler.ts | 12 +++++------- packages/openapi-sampler/tests/string.spec.ts | 16 ++++------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/openapi-sampler/src/samplers/StringSampler.ts b/packages/openapi-sampler/src/samplers/StringSampler.ts index f2f70e99..a17a1a31 100644 --- a/packages/openapi-sampler/src/samplers/StringSampler.ts +++ b/packages/openapi-sampler/src/samplers/StringSampler.ts @@ -2,7 +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': () => 'джон.сноу@таргариен.укр', @@ -153,18 +156,13 @@ export class StringSampler implements Sampler { if (boundariesStr) { throw new Error( - `Sample string cannot be generated by boundaries: ${boundariesStr}` + `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 || - (typeof boundary === 'number' && - boundary >= 0 && - boundary <= this.MAX_PATTERN_SAMPLE_LENGTH) - ); + return boundary === undefined || boundary <= this.MAX_PATTERN_SAMPLE_LENGTH; } private adjustLength(sample: string, min: number, max: number): string { diff --git a/packages/openapi-sampler/tests/string.spec.ts b/packages/openapi-sampler/tests/string.spec.ts index 42c40ffa..0ccad8bb 100644 --- a/packages/openapi-sampler/tests/string.spec.ts +++ b/packages/openapi-sampler/tests/string.spec.ts @@ -429,7 +429,7 @@ describe('StringSampler', () => { type: 'string' }, expected: - 'Sample string cannot be generated by boundaries: maxLength=9007199254740991' + 'Sample string cannot be generated by boundaries: maxLength=9007199254740991. Both minLength and maxLength must not exceed 500' }, { input: { @@ -439,7 +439,7 @@ describe('StringSampler', () => { type: 'string' }, expected: - 'Sample string cannot be generated by boundaries: minLength=9007199254740991' + 'Sample string cannot be generated by boundaries: minLength=9007199254740991. Both minLength and maxLength must not exceed 500' }, { input: { @@ -448,16 +448,8 @@ describe('StringSampler', () => { 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' - }, - { - input: { - minLength: -1, - 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=-1' + 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;