Skip to content

Commit

Permalink
fix(openapi-sampler): able to sample pattern with nested infinite qua…
Browse files Browse the repository at this point in the history
…ntifiers and max-length (#247)

closes #246
  • Loading branch information
ostridm authored Aug 14, 2024
1 parent 70d8afe commit 2b19177
Show file tree
Hide file tree
Showing 2 changed files with 131 additions and 17 deletions.
92 changes: 75 additions & 17 deletions packages/openapi-sampler/src/samplers/StringSampler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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': () => '[email protected]',
'idn-email': () => 'джон.сноу@таргариен.укр',
Expand Down Expand Up @@ -44,41 +48,73 @@ 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(
pattern: string | RegExp,
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(
Expand Down Expand Up @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions packages/openapi-sampler/tests/string.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '[email protected]'
},
{
input: {
type: 'string',
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});
});

0 comments on commit 2b19177

Please sign in to comment.