Skip to content

Commit 5ad8030

Browse files
authored
feat(schema): Add utility for detecting circular data (#35223)
1 parent b53f2a9 commit 5ad8030

File tree

3 files changed

+202
-7
lines changed

3 files changed

+202
-7
lines changed

lib/modules/manager/circleci/schema.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { z } from 'zod';
2-
import { LooseArray } from '../../../util/schema-utils';
2+
import { LooseArray, NotCircular } from '../../../util/schema-utils';
33

44
export const CircleCiDocker = z.object({
55
image: z.string(),
@@ -26,10 +26,12 @@ export const CircleCiOrb: z.ZodType<Orb> = baseOrb.extend({
2626
});
2727
export type CircleCiOrb = z.infer<typeof CircleCiOrb>;
2828

29-
export const CircleCiFile = z.object({
30-
aliases: LooseArray(CircleCiDocker).catch([]),
31-
executors: z.record(z.string(), CircleCiJob).optional(),
32-
jobs: z.record(z.string(), CircleCiJob).optional(),
33-
orbs: z.record(z.string(), z.union([z.string(), CircleCiOrb])).optional(),
34-
});
29+
export const CircleCiFile = NotCircular.pipe(
30+
z.object({
31+
aliases: LooseArray(CircleCiDocker).catch([]),
32+
executors: z.record(z.string(), CircleCiJob).optional(),
33+
jobs: z.record(z.string(), CircleCiJob).optional(),
34+
orbs: z.record(z.string(), z.union([z.string(), CircleCiOrb])).optional(),
35+
}),
36+
);
3537
export type CircleCiFile = z.infer<typeof CircleCiFile>;

lib/util/schema-utils.spec.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
LooseArray,
88
LooseRecord,
99
MultidocYaml,
10+
NotCircular,
1011
Toml,
1112
UtcDate,
1213
Yaml,
@@ -548,4 +549,152 @@ describe('util/schema-utils', () => {
548549
);
549550
});
550551
});
552+
553+
describe('NotCircular', () => {
554+
it('allows non-circular primitive values', () => {
555+
const Schema = NotCircular.pipe(z.any());
556+
557+
expect(Schema.parse(undefined)).toBeUndefined();
558+
expect(Schema.parse(null)).toBeNull();
559+
expect(Schema.parse(123)).toBe(123);
560+
expect(Schema.parse('string')).toBe('string');
561+
expect(Schema.parse(true)).toBe(true);
562+
});
563+
564+
it('allows non-circular arrays', () => {
565+
const Schema = NotCircular.pipe(z.any());
566+
567+
expect(Schema.parse([1, 2, 3])).toEqual([1, 2, 3]);
568+
expect(Schema.parse([{ a: 1 }, { b: 2 }])).toEqual([{ a: 1 }, { b: 2 }]);
569+
expect(
570+
Schema.parse([
571+
[1, 2],
572+
[3, 4],
573+
]),
574+
).toEqual([
575+
[1, 2],
576+
[3, 4],
577+
]);
578+
});
579+
580+
it('allows non-circular objects', () => {
581+
const Schema = NotCircular.pipe(z.any());
582+
583+
expect(Schema.parse({ a: 1, b: 2 })).toEqual({ a: 1, b: 2 });
584+
expect(Schema.parse({ a: { b: 1 }, c: { d: 2 } })).toEqual({
585+
a: { b: 1 },
586+
c: { d: 2 },
587+
});
588+
});
589+
590+
it('allows objects reuse', () => {
591+
const Schema = NotCircular.pipe(z.any());
592+
593+
const reused = { value: 42 };
594+
const obj = {
595+
foo: reused,
596+
bar: reused,
597+
};
598+
599+
expect(Schema.parse(obj)).toEqual({
600+
foo: { value: 42 },
601+
bar: { value: 42 },
602+
});
603+
});
604+
605+
it('rejects circular objects', () => {
606+
const Schema = NotCircular.pipe(z.any());
607+
608+
const obj: any = { a: 1 };
609+
obj.self = obj;
610+
611+
expect(Schema.safeParse(obj)).toMatchObject({
612+
success: false,
613+
error: {
614+
issues: [
615+
{
616+
code: 'custom',
617+
message: 'values cannot be circular data structures',
618+
path: [],
619+
},
620+
],
621+
},
622+
});
623+
});
624+
625+
it('rejects circular arrays', () => {
626+
const Schema = NotCircular.pipe(z.any());
627+
628+
const arr: any[] = [1, 2, 3];
629+
arr.push(arr);
630+
631+
expect(Schema.safeParse(arr)).toMatchObject({
632+
success: false,
633+
error: {
634+
issues: [
635+
{
636+
code: 'custom',
637+
message: 'values cannot be circular data structures',
638+
path: [],
639+
},
640+
],
641+
},
642+
});
643+
});
644+
645+
it('rejects deeply nested circular references', () => {
646+
const Schema = NotCircular.pipe(z.any());
647+
648+
const obj: any = {
649+
a: {
650+
b: {
651+
c: {
652+
d: {},
653+
},
654+
},
655+
},
656+
};
657+
658+
obj.a.b.c.d.circular = obj.a;
659+
660+
expect(Schema.safeParse(obj)).toMatchObject({
661+
success: false,
662+
error: {
663+
issues: [
664+
{
665+
code: 'custom',
666+
message: 'values cannot be circular data structures',
667+
path: [],
668+
},
669+
],
670+
},
671+
});
672+
});
673+
674+
it('can be combined with other schema types', () => {
675+
const Schema = z.object({
676+
data: NotCircular.pipe(z.any()),
677+
});
678+
679+
expect(Schema.parse({ data: { a: 1, b: 2 } })).toEqual({
680+
data: { a: 1, b: 2 },
681+
});
682+
683+
const obj: any = { a: 1 };
684+
obj.self = obj;
685+
686+
expect(Schema.safeParse({ data: obj })).toMatchObject({
687+
success: false,
688+
error: {
689+
issues: [
690+
{
691+
code: 'custom',
692+
message: 'values cannot be circular data structures',
693+
path: ['data'],
694+
},
695+
],
696+
},
697+
});
698+
});
699+
});
551700
});

lib/util/schema-utils.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,3 +318,47 @@ export function withTraceMessage<Input, Output>(
318318
return value;
319319
};
320320
}
321+
322+
function isCircular(value: unknown, visited = new Set<unknown>()): boolean {
323+
if (value === null || typeof value !== 'object') {
324+
return false;
325+
}
326+
327+
if (visited.has(value)) {
328+
return true;
329+
}
330+
331+
const downstreamVisited = new Set(visited);
332+
downstreamVisited.add(value);
333+
334+
if (Array.isArray(value)) {
335+
for (const childValue of value) {
336+
if (isCircular(childValue, downstreamVisited)) {
337+
return true;
338+
}
339+
}
340+
341+
return false;
342+
}
343+
344+
const values = Object.values(value);
345+
for (const ov of values) {
346+
if (isCircular(ov, downstreamVisited)) {
347+
return true;
348+
}
349+
}
350+
351+
return false;
352+
}
353+
354+
export const NotCircular = z.unknown().superRefine((val, ctx) => {
355+
if (isCircular(val)) {
356+
ctx.addIssue({
357+
code: z.ZodIssueCode.custom,
358+
message: 'values cannot be circular data structures',
359+
fatal: true,
360+
});
361+
362+
return z.NEVER;
363+
}
364+
});

0 commit comments

Comments
 (0)