fix: sanitize result filters before storing in LS (@fehmer) (#6583)

This commit is contained in:
Christian Fehmer 2025-05-26 16:06:17 +02:00 committed by GitHub
parent 19930a9079
commit 1cada77ea8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 118 additions and 40 deletions

View file

@ -1,4 +1,10 @@
import { deepClone, getErrorMessage, isObject } from "../../src/ts/utils/misc";
import { z } from "zod";
import {
deepClone,
getErrorMessage,
isObject,
sanitize,
} from "../../src/ts/utils/misc";
import {
getLanguageDisplayString,
removeLanguageSize,
@ -224,4 +230,56 @@ describe("misc.ts", () => {
});
});
});
describe("sanitize function", () => {
const schema = z.object({
name: z.string(),
age: z.number().positive(),
tags: z.array(z.string()),
});
it("should return the same object if it is valid", () => {
const obj = { name: "Alice", age: 30, tags: ["developer", "coder"] };
expect(sanitize(schema, obj)).toEqual(obj);
});
it("should remove properties with invalid values", () => {
const obj = { name: "Alice", age: -5, tags: ["developer", "coder"] };
expect(sanitize(schema, obj)).toEqual({
name: "Alice",
tags: ["developer", "coder"],
age: undefined,
});
});
it("should remove invalid array elements", () => {
const obj = {
name: "Alice",
age: 30,
tags: ["developer", 123, "coder"] as any,
};
expect(sanitize(schema, obj)).toEqual({
name: "Alice",
age: 30,
tags: ["developer", "coder"],
});
});
it("should remove entire property if all array elements are invalid", () => {
const obj = { name: "Alice", age: 30, tags: [123, 456] as any };
expect(sanitize(schema, obj)).toEqual({
name: "Alice",
age: 30,
tags: undefined,
});
});
it("should remove object properties if they are invalid", () => {
const obj = { name: 123 as any, age: 30, tags: ["developer", "coder"] };
expect(sanitize(schema, obj)).toEqual({
age: 30,
tags: ["developer", "coder"],
name: undefined,
});
});
});
});

View file

@ -55,7 +55,9 @@ const resultFiltersLS = new LocalStorageWithSchema({
if (!Misc.isObject(unknown)) {
return defaultResultFilters;
}
return mergeWithDefaultFilters(unknown as ResultFilters);
return mergeWithDefaultFilters(
Misc.sanitize(ResultFiltersSchema, unknown as ResultFilters)
);
},
});
@ -89,6 +91,7 @@ function save(): void {
export async function load(): Promise<void> {
try {
filters = resultFiltersLS.get();
console.log("###", { filters });
const newTags: Record<string, boolean> = { none: false };
Object.keys(defaultResultFilters.tags).forEach((tag) => {
@ -889,7 +892,8 @@ $(".group.presetFilterButtons .filterBtns").on(
);
function verifyResultFiltersStructure(filterIn: ResultFilters): ResultFilters {
const filter = Misc.deepClone(filterIn);
const filter = Misc.sanitize(ResultFiltersSchema, Misc.deepClone(filterIn));
Object.entries(defaultResultFilters).forEach((entry) => {
const key = entry[0] as ResultFiltersGroup;
const value = entry[1];

View file

@ -4,7 +4,7 @@ import {
PartialConfig,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { typedKeys } from "./misc";
import { sanitize, typedKeys } from "./misc";
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
import { getDefaultConfig } from "../constants/default-config";
/**
@ -33,42 +33,7 @@ function mergeWithDefaultConfig(config: PartialConfig): Config {
function sanitizeConfig(
config: ConfigSchemas.PartialConfig
): ConfigSchemas.PartialConfig {
const validate = ConfigSchemas.PartialConfigSchema.safeParse(config);
if (validate.success) {
return config;
}
const errors: Map<string, number[] | undefined> = new Map();
for (const error of validate.error.errors) {
const element = error.path[0] as string;
let val = errors.get(element);
if (typeof error.path[1] === "number") {
val = [...(val ?? []), error.path[1]];
}
errors.set(element, val);
}
return Object.fromEntries(
Object.entries(config).map(([key, value]) => {
if (!errors.has(key)) {
return [key, value];
}
const error = errors.get(key);
if (
Array.isArray(value) &&
error !== undefined && //error is not on the array itself
error.length < value.length //not all items in the array are invalid
) {
//some items of the array are invalid
return [key, value.filter((_element, index) => !error.includes(index))];
} else {
return [key, undefined];
}
})
) as ConfigSchemas.PartialConfig;
return sanitize(ConfigSchemas.PartialConfigSchema, config);
}
export function replaceLegacyValues(

View file

@ -11,6 +11,7 @@ import {
CustomTextDataWithTextLen,
Result,
} from "@monkeytype/contracts/schemas/results";
import { z } from "zod";
export function whorf(speed: number, wordlen: number): number {
return Math.min(
@ -713,4 +714,54 @@ export function promiseWithResolvers<T = void>(): {
return { resolve, reject, promise };
}
/**
* Sanitize object. Remove invalid values based on the schema.
* @param schema zod schema
* @param obj object
* @returns sanitized object
*/
export function sanitize<T extends z.ZodTypeAny>(
schema: T,
obj: z.infer<T>
): z.infer<T> {
const validate = schema.safeParse(obj);
if (validate.success) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return obj;
}
const errors: Map<string, number[] | undefined> = new Map();
for (const error of validate.error.errors) {
const element = error.path[0] as string;
let val = errors.get(element);
if (typeof error.path[1] === "number") {
val = [...(val ?? []), error.path[1]];
}
errors.set(element, val);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Object.fromEntries(
Object.entries(obj).map(([key, value]) => {
if (!errors.has(key)) {
return [key, value];
}
const error = errors.get(key);
if (
Array.isArray(value) &&
error !== undefined && //error is not on the array itself
error.length < value.length //not all items in the array are invalid
) {
//some items of the array are invalid
return [key, value.filter((_element, index) => !error.includes(index))];
} else {
return [key, undefined];
}
})
) as z.infer<T>;
}
// DO NOT ALTER GLOBAL OBJECTSONSTRUCTOR, IT WILL BREAK RESULT HASHES