committed by
philon-
57 changed files with 1225 additions and 685 deletions
@ -0,0 +1,64 @@ |
|||||
|
import type { ZodType } from "zod/v4"; |
||||
|
import type { |
||||
|
FieldError, |
||||
|
FieldValues, |
||||
|
Resolver, |
||||
|
ResolverOptions, |
||||
|
ResolverResult, |
||||
|
} from "react-hook-form"; |
||||
|
|
||||
|
export function createZodResolver<T extends FieldValues>( |
||||
|
schema: ZodType<T, unknown>, |
||||
|
): Resolver<T, unknown> { |
||||
|
return ( |
||||
|
values: T, |
||||
|
_context: unknown, |
||||
|
_options?: ResolverOptions<T>, |
||||
|
): ResolverResult<T> => { |
||||
|
const result = schema.safeParse(values); |
||||
|
if (result.success) { |
||||
|
return { |
||||
|
values: result.data, |
||||
|
errors: {}, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
const errors: Record< |
||||
|
string, |
||||
|
FieldError & { params?: Record<string, unknown> } |
||||
|
> = {}; |
||||
|
|
||||
|
for (const issue of result.error.issues) { |
||||
|
const { path, code, message, ...params } = issue; |
||||
|
const key = path.join("."); |
||||
|
|
||||
|
const suffix = "format" in params |
||||
|
? params.format |
||||
|
: "origin" in params |
||||
|
? params.origin |
||||
|
: "expected" in params |
||||
|
? params.expected |
||||
|
: ""; |
||||
|
|
||||
|
const newCode = code.replace( |
||||
|
/_([a-z])/g, |
||||
|
(_, char) => char.toUpperCase(), |
||||
|
) + (suffix ? `.${suffix}` : ""); |
||||
|
|
||||
|
const fieldError: FieldError & { params?: Record<string, unknown> } = { |
||||
|
type: newCode, |
||||
|
message: message, |
||||
|
...(Object.keys(params).length ? { params } : {}), |
||||
|
}; |
||||
|
|
||||
|
if (!errors[key]) { |
||||
|
errors[key] = fieldError; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
values: {} as T, |
||||
|
errors, |
||||
|
}; |
||||
|
}; |
||||
|
} |
||||
@ -1,30 +0,0 @@ |
|||||
import type { SecurityAction, SecurityState } from "./types.ts"; |
|
||||
|
|
||||
export function securityReducer( |
|
||||
state: SecurityState, |
|
||||
action: SecurityAction, |
|
||||
): SecurityState { |
|
||||
switch (action.type) { |
|
||||
case "SET_PRIVATE_KEY": |
|
||||
return { ...state, privateKey: action.payload }; |
|
||||
case "SET_PRIVATE_KEY_BIT_COUNT": |
|
||||
return { ...state, privateKeyBitCount: action.payload }; |
|
||||
case "SET_PUBLIC_KEY": |
|
||||
return { ...state, publicKey: action.payload }; |
|
||||
case "SET_ADMIN_KEY": |
|
||||
return { ...state, adminKey: action.payload }; |
|
||||
case "SHOW_PRIVATE_KEY_DIALOG": |
|
||||
return { ...state, privateKeyDialogOpen: action.payload }; |
|
||||
case "REGENERATE_PRIV_PUB_KEY": |
|
||||
return { |
|
||||
...state, |
|
||||
privateKey: action.payload.privateKey, |
|
||||
publicKey: action.payload.publicKey, |
|
||||
privateKeyDialogOpen: false, |
|
||||
}; |
|
||||
case "SET_TOGGLE": |
|
||||
return { ...state, [action.field]: action.payload }; |
|
||||
default: |
|
||||
return state; |
|
||||
} |
|
||||
} |
|
||||
@ -1,35 +0,0 @@ |
|||||
import { type MessageInitShape } from "@bufbuild/protobuf"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
export interface SecurityState { |
|
||||
privateKey: string; |
|
||||
privateKeyVisible: boolean; |
|
||||
adminKeyVisible: [boolean, boolean, boolean]; |
|
||||
privateKeyBitCount: number; |
|
||||
publicKey: string; |
|
||||
adminKey: [string, string, string]; |
|
||||
privateKeyDialogOpen: boolean; |
|
||||
isManaged: boolean; |
|
||||
adminChannelEnabled: boolean; |
|
||||
debugLogApiEnabled: boolean; |
|
||||
serialEnabled: boolean; |
|
||||
} |
|
||||
|
|
||||
export type SecurityAction = |
|
||||
| { type: "SET_PRIVATE_KEY"; payload: string } |
|
||||
| { type: "SET_PRIVATE_KEY_BIT_COUNT"; payload: number } |
|
||||
| { type: "SET_PUBLIC_KEY"; payload: string } |
|
||||
| { type: "SET_ADMIN_KEY"; payload: [string, string, string] } |
|
||||
| { type: "SHOW_PRIVATE_KEY_DIALOG"; payload: boolean } |
|
||||
| { type: "SET_TOGGLE"; payload: boolean; field: string } |
|
||||
| { |
|
||||
type: "REGENERATE_PRIV_PUB_KEY"; |
|
||||
payload: { privateKey: string; publicKey: string }; |
|
||||
}; |
|
||||
|
|
||||
export type SecurityConfigInit = Extract< |
|
||||
MessageInitShape< |
|
||||
typeof Protobuf.Config.ConfigSchema |
|
||||
>["payloadVariant"], |
|
||||
{ case: "security" } |
|
||||
>["value"]; |
|
||||
@ -0,0 +1,66 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { dotPaths } from "./dotPath.ts"; |
||||
|
|
||||
|
describe("dotPaths", () => { |
||||
|
it("returns flat keys for a simple object", () => { |
||||
|
const obj = { a: 1, b: 2, c: 3 }; |
||||
|
expect(dotPaths(obj)).toEqual(["a", "b", "c"]); |
||||
|
}); |
||||
|
|
||||
|
it("returns dot notation keys for nested objects", () => { |
||||
|
const obj = { a: { b: { c: 1 } }, d: 2 }; |
||||
|
expect(dotPaths(obj)).toEqual(["a.b.c", "d"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles arrays at the root", () => { |
||||
|
const arr = [{ x: 1 }, { y: 2 }]; |
||||
|
expect(dotPaths(arr)).toEqual(["0.x", "1.y"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles arrays nested in objects", () => { |
||||
|
const obj = { a: [{ b: 1 }, { c: 2 }], d: 3 }; |
||||
|
expect(dotPaths(obj)).toEqual(["a.0.b", "a.1.c", "d"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles objects nested in arrays", () => { |
||||
|
const arr = [{ a: { b: 1 } }, { c: 2 }]; |
||||
|
expect(dotPaths(arr)).toEqual(["0.a.b", "1.c"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles primitive values in arrays", () => { |
||||
|
const arr = [1, { a: 2 }, 3]; |
||||
|
expect(dotPaths(arr)).toEqual(["0", "1.a", "2"]); |
||||
|
}); |
||||
|
|
||||
|
it("handles empty objects and arrays", () => { |
||||
|
expect(dotPaths({})).toEqual([]); |
||||
|
expect(dotPaths([])).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
it("handles mixed nested structures", () => { |
||||
|
const obj = { |
||||
|
a: [ |
||||
|
{ b: 1, c: [2, 3] }, |
||||
|
{ d: { e: 4 } }, |
||||
|
], |
||||
|
f: 5, |
||||
|
}; |
||||
|
expect(dotPaths(obj)).toEqual([ |
||||
|
"a.0.b", |
||||
|
"a.0.c.0", |
||||
|
"a.0.c.1", |
||||
|
"a.1.d.e", |
||||
|
"f", |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it("handles prefix argument", () => { |
||||
|
const obj = { a: { b: 1 } }; |
||||
|
expect(dotPaths(obj, "root.")).toEqual(["root.a.b"]); |
||||
|
}); |
||||
|
|
||||
|
it("skips null and undefined values", () => { |
||||
|
const obj = { a: null, b: undefined, c: { d: 1 } }; |
||||
|
expect(dotPaths(obj)).toEqual(["a", "b", "c.d"]); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,19 @@ |
|||||
|
export type DotPath = { [key: string]: unknown } | unknown[]; |
||||
|
|
||||
|
export const dotPaths = <T extends DotPath>( |
||||
|
obj: T, |
||||
|
prefix = "", |
||||
|
): string[] => { |
||||
|
if (Array.isArray(obj)) { |
||||
|
return obj.flatMap((v, i) => |
||||
|
v && typeof v === "object" |
||||
|
? dotPaths(v as DotPath, `${prefix}${i}.`) |
||||
|
: [`${prefix}${i}`] |
||||
|
); |
||||
|
} |
||||
|
return Object.entries(obj).flatMap(([k, v]) => |
||||
|
v && typeof v === "object" |
||||
|
? dotPaths(v as DotPath, `${prefix}${k}.`) |
||||
|
: [`${prefix}${k}`] |
||||
|
); |
||||
|
}; |
||||
@ -0,0 +1,141 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { makeChannelSchema } from "./channel.ts"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
|
||||
|
const mockRole = 0; |
||||
|
|
||||
|
function makeBase64OfLength(len: number): string { |
||||
|
return fromByteArray(new Uint8Array(len)); |
||||
|
} |
||||
|
|
||||
|
describe("makeChannelSchema", () => { |
||||
|
const allowedBytes = 16; |
||||
|
const schema = makeChannelSchema(allowedBytes); |
||||
|
|
||||
|
const validBase64 = makeBase64OfLength(allowedBytes); |
||||
|
|
||||
|
const validSettings = { |
||||
|
channelNum: 3, |
||||
|
psk: validBase64, |
||||
|
name: "TestName", |
||||
|
id: 3, |
||||
|
uplinkEnabled: true, |
||||
|
downlinkEnabled: false, |
||||
|
moduleSettings: { positionPrecision: 10 }, |
||||
|
}; |
||||
|
|
||||
|
it("accepts valid channel object", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: validSettings, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects invalid base64 psk", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, psk: "not_base64!" }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("psk") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects psk of wrong length", () => { |
||||
|
const wrongLength = makeBase64OfLength(8); |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, psk: wrongLength }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("psk") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects name longer than 12 bytes", () => { |
||||
|
const longName = "a".repeat(13); |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, name: longName }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("name") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects channelNum out of range", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { ...validSettings, channelNum: 10 }, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.path.includes("settings") && i.path.includes("channelNum") |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects missing required fields", () => { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: {}, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues.length).toBeGreaterThan(0); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts moduleSettings.positionPrecision as 0, 10-19, or 32", () => { |
||||
|
for (const val of [0, 10, 15, 19, 32]) { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { |
||||
|
...validSettings, |
||||
|
moduleSettings: { positionPrecision: val }, |
||||
|
}, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects moduleSettings.positionPrecision out of range", () => { |
||||
|
for (const val of [9, 20, 31, 33]) { |
||||
|
const result = schema.safeParse({ |
||||
|
index: 0, |
||||
|
settings: { |
||||
|
...validSettings, |
||||
|
moduleSettings: { positionPrecision: val }, |
||||
|
}, |
||||
|
role: mockRole, |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -1,26 +1,38 @@ |
|||||
import { z } from "zod/v4"; |
import { z } from "zod/v4"; |
||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
|
import { makePskHelpers } from "./pskSchema.ts"; |
||||
|
import { validateMaxByteLength } from "@core/utils/string.ts"; |
||||
|
|
||||
const RoleEnum = z.enum( |
const RoleEnum = z.enum(Protobuf.Channel.Channel_Role); |
||||
Protobuf.Channel.Channel_Role, |
|
||||
); |
|
||||
|
|
||||
export const Channel_SettingsValidationSchema = z.object({ |
const moduleSettingsSchema = z.object({ |
||||
channelNum: z.number(), |
positionPrecision: z.union([ |
||||
psk: z.string(), |
z.literal(0), |
||||
name: z.string().min(0).max(11), |
z.coerce.number().int().min(10).max(19), |
||||
id: z.int(), |
z.literal(32), |
||||
uplinkEnabled: z.boolean(), |
]), |
||||
downlinkEnabled: z.boolean(), |
|
||||
positionEnabled: z.boolean(), |
|
||||
preciseLocation: z.boolean(), |
|
||||
positionPrecision: z.boolean(), |
|
||||
}); |
}); |
||||
|
|
||||
export const ChannelValidationSchema = z.object({ |
export function makeChannelSchema(allowedBytes: number) { |
||||
index: z.number(), |
const { stringSchema } = makePskHelpers([allowedBytes]); |
||||
settings: Channel_SettingsValidationSchema, |
|
||||
role: RoleEnum, |
const ChannelSettingsSchema = z.object({ |
||||
}); |
channelNum: z.coerce.number().int().min(0).max(7), |
||||
|
psk: stringSchema(false), |
||||
|
name: z.string() |
||||
|
.refine( |
||||
|
(s) => validateMaxByteLength(s, 12).isValid, |
||||
|
{ message: "formValidation.tooBig.bytes", params: { maximum: 12 } }, |
||||
|
), |
||||
|
id: z.coerce.number().int(), |
||||
|
uplinkEnabled: z.boolean(), |
||||
|
downlinkEnabled: z.boolean(), |
||||
|
moduleSettings: moduleSettingsSchema, |
||||
|
}); |
||||
|
|
||||
export type ChannelValidation = z.infer<typeof ChannelValidationSchema>; |
return z.object({ |
||||
|
index: z.coerce.number(), |
||||
|
settings: ChannelSettingsSchema, |
||||
|
role: RoleEnum, |
||||
|
}); |
||||
|
} |
||||
|
|||||
@ -0,0 +1,113 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
import { ParsedSecuritySchema, RawSecuritySchema } from "./security.ts"; |
||||
|
|
||||
|
function makeBase64OfLength(len: number): string { |
||||
|
return fromByteArray(new Uint8Array(len)); |
||||
|
} |
||||
|
|
||||
|
describe("RawSecuritySchema", () => { |
||||
|
const validKey = makeBase64OfLength(32); |
||||
|
|
||||
|
it("accepts valid security config", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: false, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects if privateKey is invalid", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: false, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: "badkey", |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues.some((i) => i.path.includes("privateKey"))) |
||||
|
.toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("requires at least one adminKey if isManaged", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: true, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: ["", "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.message === "formValidation.adminKeyRequiredWhenManaged" |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts if at least one adminKey is valid when isManaged", () => { |
||||
|
const result = RawSecuritySchema.safeParse({ |
||||
|
isManaged: true, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, "", ""], |
||||
|
}); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe("ParsedSecuritySchema", () => { |
||||
|
const validKey = new Uint8Array(32); |
||||
|
|
||||
|
it("accepts valid parsed security config", () => { |
||||
|
const result = ParsedSecuritySchema.safeParse({ |
||||
|
isManaged: false, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [validKey, new Uint8Array(), new Uint8Array()], |
||||
|
}); |
||||
|
console.log(result); |
||||
|
|
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("requires at least one adminKey if isManaged", () => { |
||||
|
const result = ParsedSecuritySchema.safeParse({ |
||||
|
isManaged: true, |
||||
|
adminChannelEnabled: true, |
||||
|
debugLogApiEnabled: false, |
||||
|
serialEnabled: true, |
||||
|
privateKey: validKey, |
||||
|
publicKey: validKey, |
||||
|
adminKey: [new Uint8Array(), new Uint8Array(), new Uint8Array()], |
||||
|
}); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect( |
||||
|
result.error.issues.some((i) => |
||||
|
i.message === "formValidation.adminKeyRequiredWhenManaged" |
||||
|
), |
||||
|
).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
@ -1,14 +1,47 @@ |
|||||
import { z } from "zod/v4"; |
import { z, ZodType } from "zod/v4"; |
||||
|
import { makePskHelpers } from "./../pskSchema.ts"; |
||||
export const SecurityValidationSchema = z.object({ |
|
||||
adminChannelEnabled: z.boolean(), |
const { |
||||
adminKey: z.string().array().length(3), |
stringSchema, |
||||
bluetoothLoggingEnabled: z.boolean(), |
bytesSchema, |
||||
debugLogApiEnabled: z.boolean(), |
isValidKey, |
||||
isManaged: z.boolean(), |
} = makePskHelpers([32]); // 256-bit
|
||||
privateKey: z.string(), |
|
||||
publicKey: z.string(), |
const isManagedRequiredMsg = "formValidation.adminKeyRequiredWhenManaged"; |
||||
serialEnabled: z.boolean(), |
|
||||
}); |
function makeSecuritySchema<KeyT>( |
||||
|
keyMaker: (optional: boolean) => ZodType<KeyT>, |
||||
export type SecurityValidation = z.infer<typeof SecurityValidationSchema>; |
) { |
||||
|
return z |
||||
|
.object({ |
||||
|
isManaged: z.boolean(), |
||||
|
adminChannelEnabled: z.boolean(), |
||||
|
debugLogApiEnabled: z.boolean(), |
||||
|
serialEnabled: z.boolean(), |
||||
|
|
||||
|
privateKey: keyMaker(false), |
||||
|
publicKey: keyMaker(false), |
||||
|
adminKey: z.tuple([keyMaker(true), keyMaker(true), keyMaker(true)]), |
||||
|
}) |
||||
|
.check((ctx) => { |
||||
|
if (ctx.value.isManaged) { |
||||
|
const hasAdmin = ctx.value.adminKey.some(isValidKey); |
||||
|
if (!hasAdmin) { |
||||
|
for (const path of [["isManaged"], ["adminKey", 0]] as const) { |
||||
|
ctx.issues.push({ |
||||
|
code: "custom", |
||||
|
message: isManagedRequiredMsg, |
||||
|
path: [...path], |
||||
|
input: ctx.value, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
export const RawSecuritySchema = makeSecuritySchema(stringSchema); |
||||
|
export type RawSecurity = z.infer<typeof RawSecuritySchema>; |
||||
|
|
||||
|
export const ParsedSecuritySchema = makeSecuritySchema(bytesSchema); |
||||
|
export type ParsedSecurity = z.infer<typeof ParsedSecuritySchema>; |
||||
|
|||||
@ -1,18 +1,18 @@ |
|||||
import { z } from "zod/v4"; |
import { z } from "zod/v4"; |
||||
|
|
||||
export const StoreForwardValidationSchema = z.object({ |
export const TelemetryValidationSchema = z.object({ |
||||
deviceUpdateInterval: z.int(), |
deviceUpdateInterval: z.coerce.number().int().min(0), |
||||
environmentUpdateInterval: z.int(), |
environmentUpdateInterval: z.coerce.number().int().min(0), |
||||
environmentMeasurementEnabled: z.boolean(), |
environmentMeasurementEnabled: z.boolean(), |
||||
environmentScreenEnabled: z.boolean(), |
environmentScreenEnabled: z.boolean(), |
||||
environmentDisplayFahrenheit: z.boolean(), |
environmentDisplayFahrenheit: z.boolean(), |
||||
airQualityEnabled: z.boolean(), |
airQualityEnabled: z.boolean(), |
||||
airQualityInterval: z.int(), |
airQualityInterval: z.coerce.number().int().min(0), |
||||
powerMeasurementEnabled: z.boolean(), |
powerMeasurementEnabled: z.boolean(), |
||||
powerUpdateInterval: z.int(), |
powerUpdateInterval: z.coerce.number().int().min(0), |
||||
powerScreenEnabled: z.boolean(), |
powerScreenEnabled: z.boolean(), |
||||
}); |
}); |
||||
|
|
||||
export type StoreForwardValidation = z.infer< |
export type TelemetryValidation = z.infer< |
||||
typeof StoreForwardValidationSchema |
typeof TelemetryValidationSchema |
||||
>; |
>; |
||||
|
|||||
@ -0,0 +1,174 @@ |
|||||
|
import { describe, expect, it } from "vitest"; |
||||
|
import { makePskHelpers } from "./pskSchema.ts"; |
||||
|
import { fromByteArray } from "base64-js"; |
||||
|
|
||||
|
function makeBase64OfLength(len: number): string { |
||||
|
return fromByteArray(new Uint8Array(len)); |
||||
|
} |
||||
|
|
||||
|
describe("stringSchema", () => { |
||||
|
it("accepts valid base64 string of allowed length", () => { |
||||
|
const { stringSchema } = makePskHelpers([16]); |
||||
|
const valid = makeBase64OfLength(16); |
||||
|
expect(() => stringSchema().parse(valid)).not.toThrow(); |
||||
|
}); |
||||
|
|
||||
|
it("rejects base64 string of disallowed length", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const invalid = makeBase64OfLength(8); |
||||
|
const result = stringSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects invalid base64 string", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const result = stringSchema().safeParse("not_base64!"); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.format); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects empty string if not optional and 0 not allowed", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const result = stringSchema().safeParse(""); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.required); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty string if 0 is allowed", () => { |
||||
|
const { stringSchema } = makePskHelpers([0]); |
||||
|
const result = stringSchema().safeParse(""); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty string if optional=true", () => { |
||||
|
const { stringSchema } = makePskHelpers([16]); |
||||
|
const result = stringSchema(true).safeParse(""); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts all allowed lengths", () => { |
||||
|
const { stringSchema } = makePskHelpers([8, 16, 32]); |
||||
|
for (const len of [8, 16, 32]) { |
||||
|
const valid = makeBase64OfLength(len); |
||||
|
const result = stringSchema().safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts valid base64 string as optional when optional=true", () => { |
||||
|
const { stringSchema } = makePskHelpers([16]); |
||||
|
const valid = makeBase64OfLength(16); |
||||
|
const result = stringSchema(true).safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects base64 string with correct length but extra padding", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([16]); |
||||
|
const valid = makeBase64OfLength(16) + "=="; |
||||
|
const result = stringSchema().safeParse(valid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.format); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty string if allowedByteLengths includes 0 and optional=false", () => { |
||||
|
const { stringSchema } = makePskHelpers([0, 16]); |
||||
|
const result = stringSchema(false).safeParse(""); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects base64 string with valid format but not in allowedByteLengths", () => { |
||||
|
const { stringSchema, msgs } = makePskHelpers([8, 32]); |
||||
|
const invalid = makeBase64OfLength(16); |
||||
|
const result = stringSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
describe("bytesSchema", () => { |
||||
|
it("accepts valid byte array of allowed length", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const valid = new Uint8Array(16); |
||||
|
expect(() => bytesSchema().parse(valid)).not.toThrow(); |
||||
|
}); |
||||
|
|
||||
|
it("rejects byte array of disallowed length", () => { |
||||
|
const { bytesSchema, msgs } = makePskHelpers([16]); |
||||
|
const invalid = new Uint8Array(8); |
||||
|
const result = bytesSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("rejects non-Uint8Array input", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const result = bytesSchema().safeParse([1, 2, 3]); |
||||
|
expect(result.success).toBe(false); |
||||
|
}); |
||||
|
|
||||
|
it("rejects empty array if not optional and 0 not allowed", () => { |
||||
|
const { bytesSchema, msgs } = makePskHelpers([16]); |
||||
|
const result = bytesSchema().safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.required); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty array if 0 is allowed", () => { |
||||
|
const { bytesSchema } = makePskHelpers([0]); |
||||
|
const result = bytesSchema().safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty array if optional=true", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const result = bytesSchema(true).safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts all allowed lengths", () => { |
||||
|
const { bytesSchema } = makePskHelpers([8, 16, 32]); |
||||
|
for (const len of [8, 16, 32]) { |
||||
|
const valid = new Uint8Array(len); |
||||
|
const result = bytesSchema().safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it("accepts valid byte array as optional when optional=true", () => { |
||||
|
const { bytesSchema } = makePskHelpers([16]); |
||||
|
const valid = new Uint8Array(16); |
||||
|
const result = bytesSchema(true).safeParse(valid); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("accepts empty array if allowedByteLengths includes 0 and optional=false", () => { |
||||
|
const { bytesSchema } = makePskHelpers([0, 16]); |
||||
|
const result = bytesSchema(false).safeParse(new Uint8Array(0)); |
||||
|
expect(result.success).toBe(true); |
||||
|
}); |
||||
|
|
||||
|
it("rejects byte array with valid format but not in allowedByteLengths", () => { |
||||
|
const { bytesSchema, msgs } = makePskHelpers([8, 32]); |
||||
|
const invalid = new Uint8Array(16); |
||||
|
const result = bytesSchema().safeParse(invalid); |
||||
|
expect(result.success).toBe(false); |
||||
|
if (!result.success) { |
||||
|
expect(result.error.issues[0].message).toBe(msgs.length); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,70 @@ |
|||||
|
import { z, ZodType } from "zod/v4"; |
||||
|
import { toByteArray } from "base64-js"; |
||||
|
|
||||
|
export function makePskHelpers( |
||||
|
allowedByteLengths: readonly number[], |
||||
|
) { |
||||
|
const bitsLabel = allowedByteLengths.map((b) => b * 8).join(" | "); |
||||
|
const msgs = { |
||||
|
format: "formValidation.invalidFormat.key", |
||||
|
required: "formValidation.required.key", |
||||
|
length: `formValidation.pskLength.${bitsLabel.replace(/ \| /g, "_")}bit`, |
||||
|
} as const; |
||||
|
|
||||
|
function tryParse(str: string): Uint8Array | null { |
||||
|
try { |
||||
|
return toByteArray(str); |
||||
|
} catch { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
function isValidString(str: string): boolean { |
||||
|
const arr = tryParse(str); |
||||
|
return arr !== null && |
||||
|
allowedByteLengths.includes(arr.byteLength); |
||||
|
} |
||||
|
|
||||
|
function isValidKey(v: unknown): boolean { |
||||
|
if (typeof v === "string") return isValidString(v); |
||||
|
if (v instanceof Uint8Array) { |
||||
|
return allowedByteLengths.includes(v.byteLength); |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const stringSchema = (optional = false) => |
||||
|
z.string() |
||||
|
.refine((s) => |
||||
|
optional || s !== "" || (s === "" && allowedByteLengths.includes(0)), { |
||||
|
message: msgs.required, |
||||
|
}) |
||||
|
.refine((s) => |
||||
|
s === "" || tryParse(s) !== null, { message: msgs.format }) |
||||
|
.refine((s) => |
||||
|
s === "" || isValidString(s), { |
||||
|
message: msgs.length, |
||||
|
params: { bits: bitsLabel }, |
||||
|
}); |
||||
|
|
||||
|
const bytesSchema = (optional = false): ZodType<Uint8Array> => |
||||
|
z.instanceof(Uint8Array) |
||||
|
.refine( |
||||
|
(arr) => |
||||
|
optional || arr.byteLength !== 0 || allowedByteLengths.includes(0), |
||||
|
{ message: msgs.required }, |
||||
|
) |
||||
|
.refine( |
||||
|
(arr) => optional || allowedByteLengths.includes(arr.byteLength), |
||||
|
{ message: msgs.length, params: { bits: bitsLabel } }, |
||||
|
); |
||||
|
|
||||
|
return { |
||||
|
allowedByteLengths, |
||||
|
msgs, |
||||
|
tryParseStringKey: tryParse, |
||||
|
isValidKey, |
||||
|
stringSchema, |
||||
|
bytesSchema, |
||||
|
}; |
||||
|
} |
||||
Loading…
Reference in new issue