Add schema utils

This commit is contained in:
Jamie Kyle 2024-10-02 12:03:10 -07:00 committed by GitHub
parent c8a729f8be
commit b26466e59d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 674 additions and 151 deletions

View file

@ -9,6 +9,7 @@ import LRU from 'lru-cache';
import type { OptionalResourceService } from './OptionalResourceService';
import { SignalService as Proto } from '../ts/protobuf';
import { parseUnknown } from '../ts/util/schemas';
const MANIFEST_PATH = join(__dirname, '..', 'build', 'jumbomoji.json');
@ -64,8 +65,9 @@ export class EmojiService {
public static async create(
resourceService: OptionalResourceService
): Promise<EmojiService> {
const json = await readFile(MANIFEST_PATH, 'utf8');
const manifest = manifestSchema.parse(JSON.parse(json));
const contents = await readFile(MANIFEST_PATH, 'utf8');
const json: unknown = JSON.parse(contents);
const manifest = parseUnknown(manifestSchema, json);
return new EmojiService(resourceService, manifest);
}

View file

@ -17,6 +17,7 @@ import { OptionalResourcesDictSchema } from '../ts/types/OptionalResource';
import * as log from '../ts/logging/log';
import { getGotOptions } from '../ts/updater/got';
import { drop } from '../ts/util/drop';
import { parseUnknown } from '../ts/util/schemas';
const RESOURCES_DICT_PATH = join(
__dirname,
@ -106,8 +107,10 @@ export class OptionalResourceService {
return;
}
const json = JSON.parse(await readFile(RESOURCES_DICT_PATH, 'utf8'));
this.maybeDeclaration = OptionalResourcesDictSchema.parse(json);
const json: unknown = JSON.parse(
await readFile(RESOURCES_DICT_PATH, 'utf8')
);
this.maybeDeclaration = parseUnknown(OptionalResourcesDictSchema, json);
// Clean unknown resources
let subPaths: Array<string>;

View file

@ -56,6 +56,7 @@ import {
isVideoTypeSupported,
} from '../ts/util/GoogleChrome';
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
import { parseLoose } from '../ts/util/schemas';
let initialized = false;
@ -471,7 +472,7 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
let disposition: z.infer<typeof dispositionSchema> = 'attachment';
const dispositionParam = url.searchParams.get('disposition');
if (dispositionParam != null) {
disposition = dispositionSchema.parse(dispositionParam);
disposition = parseLoose(dispositionSchema, dispositionParam);
}
strictAssert(attachmentsDir != null, 'not initialized');

View file

@ -12,6 +12,7 @@ import * as Errors from '../ts/types/errors';
import { isProduction } from '../ts/util/version';
import { isNotNil } from '../ts/util/isNotNil';
import OS from '../ts/util/os/osMain';
import { parseUnknown } from '../ts/util/schemas';
// See https://github.com/rust-minidump/rust-minidump/blob/main/minidump-processor/json-schema.md
const dumpString = z.string().or(z.null()).optional();
@ -120,9 +121,8 @@ export function setup(
pendingDumps.map(async fullPath => {
const content = await readFile(fullPath);
try {
const dump = dumpSchema.parse(
JSON.parse(dumpToJSONString(content))
);
const json: unknown = JSON.parse(dumpToJSONString(content));
const dump = parseUnknown(dumpSchema, json);
if (dump.crash_info?.type !== 'Simulated Exception') {
return fullPath;
}
@ -173,7 +173,8 @@ export function setup(
const content = await readFile(fullPath);
const { mtime } = await stat(fullPath);
const dump = dumpSchema.parse(JSON.parse(dumpToJSONString(content)));
const json: unknown = JSON.parse(dumpToJSONString(content));
const dump = parseUnknown(dumpSchema, json);
if (dump.crash_info?.type === 'Simulated Exception') {
return undefined;

View file

@ -5,6 +5,7 @@ import { join } from 'path';
import { readFile } from 'fs/promises';
import { DNSFallbackSchema } from '../ts/types/DNSFallback';
import type { DNSFallbackType } from '../ts/types/DNSFallback';
import { parseUnknown } from '../ts/util/schemas';
let cached: DNSFallbackType | undefined;
@ -25,9 +26,9 @@ export async function getDNSFallback(): Promise<DNSFallbackType> {
return cached;
}
const json = JSON.parse(str);
const json: unknown = JSON.parse(str);
const result = DNSFallbackSchema.parse(json);
const result = parseUnknown(DNSFallbackSchema, json);
cached = result;
return result;
}

View file

@ -13,6 +13,7 @@ import type { LoggerType } from '../ts/types/Logging';
import type { HourCyclePreference, LocaleMessagesType } from '../ts/types/I18N';
import type { LocalizerType } from '../ts/types/Util';
import * as Errors from '../ts/types/errors';
import { parseUnknown } from '../ts/util/schemas';
const TextInfoSchema = z.object({
direction: z.enum(['ltr', 'rtl']),
@ -70,16 +71,18 @@ function getLocaleDirection(
try {
// @ts-expect-error -- TS doesn't know about this method
if (typeof locale.getTextInfo === 'function') {
return TextInfoSchema.parse(
return parseUnknown(
TextInfoSchema,
// @ts-expect-error -- TS doesn't know about this method
locale.getTextInfo()
locale.getTextInfo() as unknown
).direction;
}
// @ts-expect-error -- TS doesn't know about this property
if (typeof locale.textInfo === 'object') {
return TextInfoSchema.parse(
return parseUnknown(
TextInfoSchema,
// @ts-expect-error -- TS doesn't know about this property
locale.textInfo
locale.textInfo as unknown
).direction;
}
} catch (error) {

View file

@ -123,6 +123,7 @@ import { ZoomFactorService } from '../ts/services/ZoomFactorService';
import { SafeStorageBackendChangeError } from '../ts/types/SafeStorageBackendChangeError';
import { LINUX_PASSWORD_STORE_FLAGS } from '../ts/util/linuxPasswordStoreFlags';
import { getOwn } from '../ts/util/getOwn';
import { safeParseLoose, safeParseUnknown } from '../ts/util/schemas';
const animationSettings = systemPreferences.getAnimationSettings();
@ -436,7 +437,8 @@ export const windowConfigSchema = z.object({
type WindowConfigType = z.infer<typeof windowConfigSchema>;
let windowConfig: WindowConfigType | undefined;
const windowConfigParsed = windowConfigSchema.safeParse(
const windowConfigParsed = safeParseUnknown(
windowConfigSchema,
windowFromEphemeral || windowFromUserConfig
);
if (windowConfigParsed.success) {
@ -2692,7 +2694,7 @@ ipc.on('delete-all-data', () => {
ipc.on('get-config', async event => {
const theme = await getResolvedThemeSetting();
const directoryConfig = directoryConfigSchema.safeParse({
const directoryConfig = safeParseLoose(directoryConfigSchema, {
directoryUrl: config.get<string | null>('directoryUrl') || undefined,
directoryMRENCLAVE:
config.get<string | null>('directoryMRENCLAVE') || undefined,
@ -2705,7 +2707,7 @@ ipc.on('get-config', async event => {
);
}
const parsed = rendererConfigSchema.safeParse({
const parsed = safeParseLoose(rendererConfigSchema, {
name: packageJson.productName,
availableLocales: getResolvedMessagesLocale().availableLocales,
resolvedTranslationsLocale: getResolvedMessagesLocale().name,

14
patches/zod+3.22.3.patch Normal file
View file

@ -0,0 +1,14 @@
diff --git a/node_modules/zod/lib/types.d.ts b/node_modules/zod/lib/types.d.ts
index 0ece6e8..57bbe86 100644
--- a/node_modules/zod/lib/types.d.ts
+++ b/node_modules/zod/lib/types.d.ts
@@ -56,7 +56,9 @@ export declare abstract class ZodType<Output = any, Def extends ZodTypeDef = Zod
};
_parseSync(input: ParseInput): SyncParseReturnType<Output>;
_parseAsync(input: ParseInput): AsyncParseReturnType<Output>;
+ /** @deprecated (Signal Desktop: Use ts/util/schema.ts instead) */
parse(data: unknown, params?: Partial<ParseParams>): Output;
+ /** @deprecated (Signal Desktop: Use ts/util/schema.ts instead) */
safeParse(data: unknown, params?: Partial<ParseParams>): SafeParseReturnType<Input, Output>;
parseAsync(data: unknown, params?: Partial<ParseParams>): Promise<Output>;
safeParseAsync(data: unknown, params?: Partial<ParseParams>): Promise<SafeParseReturnType<Input, Output>>;

View file

@ -67,6 +67,7 @@ import {
SIGNED_PRE_KEY_ID_KEY,
} from './textsecure/AccountManager';
import { formatGroups, groupWhile } from './util/groupWhile';
import { parseUnknown } from './util/schemas';
const TIMESTAMP_THRESHOLD = 5 * 1000; // 5 seconds
const LOW_KEYS_THRESHOLD = 25;
@ -99,7 +100,7 @@ const identityKeySchema = z.object({
function validateIdentityKey(attrs: unknown): attrs is IdentityKeyType {
// We'll throw if this doesn't match
identityKeySchema.parse(attrs);
parseUnknown(identityKeySchema, attrs);
return true;
}
/*

View file

@ -9,6 +9,7 @@ import * as log from '../logging/log';
import type { BadgeType, BadgeImageType } from './types';
import { parseBadgeCategory } from './BadgeCategory';
import { BadgeImageTheme, parseBadgeImageTheme } from './BadgeImageTheme';
import { safeParseUnknown } from '../util/schemas';
const MAX_BADGES = 1000;
@ -40,7 +41,7 @@ export function parseBoostBadgeListFromServer(
): Record<string, BadgeType> {
const result: Record<string, BadgeType> = {};
const parseResult = boostBadgesFromServerSchema.safeParse(value);
const parseResult = safeParseUnknown(boostBadgesFromServerSchema, value);
if (!parseResult.success) {
log.warn(
'parseBoostBadgeListFromServer: server response was invalid:',
@ -73,7 +74,7 @@ export function parseBadgeFromServer(
value: unknown,
updatesUrl: string
): BadgeType | undefined {
const parseResult = badgeFromServerSchema.safeParse(value);
const parseResult = safeParseUnknown(badgeFromServerSchema, value);
if (!parseResult.success) {
log.warn(
'parseBadgeFromServer: badge was invalid:',

View file

@ -15,6 +15,7 @@ import { Input } from './Input';
import { AutoSizeTextArea } from './AutoSizeTextArea';
import { Button, ButtonVariant } from './Button';
import { strictAssert } from '../util/assert';
import { safeParsePartial } from '../util/schemas';
const formSchema = z.object({
nickname: z
@ -67,7 +68,7 @@ export function EditNicknameAndNoteModal({
const familyNameValue = toOptionalStringValue(familyName);
const noteValue = toOptionalStringValue(note);
const hasEitherName = givenNameValue != null || familyNameValue != null;
return formSchema.safeParse({
return safeParsePartial(formSchema, {
nickname: hasEitherName
? { givenName: givenNameValue, familyName: familyNameValue }
: null,

View file

@ -45,6 +45,7 @@ import type { MIMEType } from '../types/MIME';
import { AttachmentDownloadSource } from '../sql/Interface';
import { drop } from '../util/drop';
import { getAttachmentCiphertextLength } from '../AttachmentCrypto';
import { safeParsePartial } from '../util/schemas';
export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate',
@ -175,7 +176,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
source,
urgency = AttachmentDownloadUrgency.STANDARD,
} = newJobData;
const parseResult = coreAttachmentDownloadJobSchema.safeParse({
const parseResult = safeParsePartial(coreAttachmentDownloadJobSchema, {
messageId,
receivedAt,
sentAt,

View file

@ -16,6 +16,7 @@ import { DataReader, DataWriter } from '../sql/Client';
import type { CallLinkType } from '../types/CallLink';
import { calling } from '../services/calling';
import { sleeper } from '../util/sleeper';
import { parseUnknown } from '../util/schemas';
const MAX_RETRY_TIME = DAY;
const MAX_PARALLEL_JOBS = 5;
@ -46,7 +47,7 @@ export class CallLinkRefreshJobQueue extends JobQueue<CallLinkRefreshJobData> {
}
protected parseData(data: unknown): CallLinkRefreshJobData {
return callLinkRefreshJobDataSchema.parse(data);
return parseUnknown(callLinkRefreshJobDataSchema, data);
}
protected async run(

View file

@ -50,6 +50,7 @@ import { drop } from '../util/drop';
import { isInPast } from '../util/timestamp';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { FIBONACCI } from '../util/BackOff';
import { parseUnknown } from '../util/schemas';
// Note: generally, we only want to add to this list. If you do need to change one of
// these values, you'll likely need to write a database migration.
@ -415,7 +416,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
}
protected parseData(data: unknown): ConversationQueueJobData {
return conversationQueueJobDataSchema.parse(data);
return parseUnknown(conversationQueueJobDataSchema, data);
}
protected override getInMemoryQueue({

View file

@ -10,6 +10,7 @@ import { DataWriter } from '../sql/Client';
import type { JOB_STATUS } from './JobQueue';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
import { parseUnknown } from '../util/schemas';
const groupAvatarJobDataSchema = z.object({
conversationId: z.string(),
@ -20,7 +21,7 @@ export type GroupAvatarJobData = z.infer<typeof groupAvatarJobDataSchema>;
export class GroupAvatarJobQueue extends JobQueue<GroupAvatarJobData> {
protected parseData(data: unknown): GroupAvatarJobData {
return groupAvatarJobDataSchema.parse(data);
return parseUnknown(groupAvatarJobDataSchema, data);
}
protected async run(

View file

@ -7,6 +7,7 @@ import type { JOB_STATUS } from './JobQueue';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
import { parseUnknown } from '../util/schemas';
const removeStorageKeyJobDataSchema = z.object({
key: z.enum([
@ -22,7 +23,7 @@ type RemoveStorageKeyJobData = z.infer<typeof removeStorageKeyJobDataSchema>;
export class RemoveStorageKeyJobQueue extends JobQueue<RemoveStorageKeyJobData> {
protected parseData(data: unknown): RemoveStorageKeyJobData {
return removeStorageKeyJobDataSchema.parse(data);
return parseUnknown(removeStorageKeyJobDataSchema, data);
}
protected async run({

View file

@ -17,6 +17,7 @@ import { parseIntWithFallback } from '../util/parseIntWithFallback';
import type { WebAPIType } from '../textsecure/WebAPI';
import { HTTPError } from '../textsecure/Errors';
import { sleeper } from '../util/sleeper';
import { parseUnknown } from '../util/schemas';
const RETRY_WAIT_TIME = durations.MINUTE;
const RETRYABLE_4XX_FAILURE_STATUSES = new Set([
@ -44,7 +45,7 @@ export class ReportSpamJobQueue extends JobQueue<ReportSpamJobData> {
}
protected parseData(data: unknown): ReportSpamJobData {
return reportSpamJobDataSchema.parse(data);
return parseUnknown(reportSpamJobDataSchema, data);
}
protected async run(

View file

@ -24,6 +24,7 @@ import {
} from './helpers/handleMultipleSendErrors';
import { isConversationUnregistered } from '../util/isConversationUnregistered';
import { isConversationAccepted } from '../util/isConversationAccepted';
import { parseUnknown } from '../util/schemas';
const MAX_RETRY_TIME = DAY;
const MAX_PARALLEL_JOBS = 5;
@ -43,7 +44,7 @@ export class SingleProtoJobQueue extends JobQueue<SingleProtoJobData> {
}
protected parseData(data: unknown): SingleProtoJobData {
return singleProtoJobDataSchema.parse(data);
return parseUnknown(singleProtoJobDataSchema, data);
}
protected async run(

View file

@ -11,6 +11,7 @@ import { getUserAgent } from '../util/getUserAgent';
import { maybeParseUrl } from '../util/url';
import * as durations from '../util/durations';
import type { LoggerType } from '../types/Logging';
import { parseUnknown } from '../util/schemas';
const BASE_URL = 'https://debuglogs.org';
@ -26,7 +27,7 @@ const tokenBodySchema = z
const parseTokenBody = (
rawBody: unknown
): { fields: Record<string, unknown>; url: string } => {
const body = tokenBodySchema.parse(rawBody);
const body = parseUnknown(tokenBodySchema, rawBody);
const parsedUrl = maybeParseUrl(body.url);
if (!parsedUrl) {

View file

@ -5,6 +5,7 @@ import { parse } from 'csv-parse';
import fs from 'fs/promises';
import { z } from 'zod';
import { _getAvailableLocales } from '../../app/locale';
import { parseUnknown } from '../util/schemas';
const type = process.argv[2];
if (type !== 'countries' && type !== 'locales') {
@ -119,7 +120,7 @@ function assertValuesForAllCountries(result: LocaleDisplayNamesResult) {
async function main() {
const contents = await fs.readFile(localeDisplayNamesDataPath, 'utf-8');
const records = await parseCsv(contents);
const data = LocaleDisplayNames.parse(records);
const data = parseUnknown(LocaleDisplayNames, records as unknown);
const result = convertData(data);
if (type === 'locales') {
assertValuesForAllLocales(result);

View file

@ -9,6 +9,7 @@ import prettier from 'prettier';
import type { OptionalResourceType } from '../types/OptionalResource';
import { OptionalResourcesDictSchema } from '../types/OptionalResource';
import { parseUnknown } from '../util/schemas';
const MANIFEST_URL =
'https://updates.signal.org/dynamic/android/emoji/search/manifest.json';
@ -29,7 +30,7 @@ async function fetchJSON(url: string): Promise<unknown> {
}
async function main(): Promise<void> {
const manifest = ManifestSchema.parse(await fetchJSON(MANIFEST_URL));
const manifest = parseUnknown(ManifestSchema, await fetchJSON(MANIFEST_URL));
// eslint-disable-next-line dot-notation
manifest.languageToSmartlingLocale['zh_TW'] = 'zh-Hant';
@ -75,8 +76,9 @@ async function main(): Promise<void> {
'build',
'optional-resources.json'
);
const resources = OptionalResourcesDictSchema.parse(
JSON.parse(await readFile(resourcesPath, 'utf8'))
const resources = parseUnknown(
OptionalResourcesDictSchema,
JSON.parse(await readFile(resourcesPath, 'utf8')) as unknown
);
for (const [locale, resource] of extraResources) {

View file

@ -9,6 +9,7 @@ import prettier from 'prettier';
import type { OptionalResourceType } from '../types/OptionalResource';
import { OptionalResourcesDictSchema } from '../types/OptionalResource';
import { parseUnknown } from '../util/schemas';
const VERSION = 10;
@ -28,7 +29,10 @@ async function fetchJSON(url: string): Promise<unknown> {
}
async function main(): Promise<void> {
const { jumbomoji } = ManifestSchema.parse(await fetchJSON(MANIFEST_URL));
const { jumbomoji } = parseUnknown(
ManifestSchema,
await fetchJSON(MANIFEST_URL)
);
const extraResources = new Map<string, OptionalResourceType>();
@ -68,8 +72,9 @@ async function main(): Promise<void> {
'build',
'optional-resources.json'
);
const resources = OptionalResourcesDictSchema.parse(
JSON.parse(await readFile(resourcesPath, 'utf8'))
const resources = parseUnknown(
OptionalResourcesDictSchema,
JSON.parse(await readFile(resourcesPath, 'utf8')) as unknown
);
for (const [sheet, resource] of extraResources) {

View file

@ -13,6 +13,7 @@ import logSymbols from 'log-symbols';
import { explodePromise } from '../util/explodePromise';
import { missingCaseError } from '../util/missingCaseError';
import { SECOND } from '../util/durations';
import { parseUnknown } from '../util/schemas';
const ROOT_DIR = join(__dirname, '..', '..');
@ -137,7 +138,10 @@ async function launchElectron(
return;
}
const event = eventSchema.parse(JSON.parse(match[1]));
const event = parseUnknown(
eventSchema,
JSON.parse(match[1]) as unknown
);
if (event.type === 'pass') {
pass += 1;

View file

@ -17,9 +17,10 @@ import type { WebAPIType } from '../textsecure/WebAPI';
import { SignalService as Proto } from '../protobuf';
import SenderCertificate = Proto.SenderCertificate;
import { safeParseUnknown } from '../util/schemas';
function isWellFormed(data: unknown): data is SerializedCertificateType {
return serializedCertificateSchema.safeParse(data).success;
return safeParseUnknown(serializedCertificateSchema, data).success;
}
// In case your clock is different from the server's, we "fake" expire certificates early.

View file

@ -207,6 +207,7 @@ import {
} from '../types/AttachmentBackup';
import { redactGenericText } from '../util/privacy';
import { getAttachmentCiphertextLength } from '../AttachmentCrypto';
import { parseStrict, parseUnknown, safeParseUnknown } from '../util/schemas';
type ConversationRow = Readonly<{
json: string;
@ -3664,7 +3665,7 @@ function getCallHistory(
return;
}
return callHistoryDetailsSchema.parse(row);
return parseUnknown(callHistoryDetailsSchema, row as unknown);
}
const SEEN_STATUS_UNSEEN = sqlConstant(SeenStatus.Unseen);
@ -3746,7 +3747,7 @@ function getCallHistoryForCallLogEventTarget(
return null;
}
return callHistoryDetailsSchema.parse(row);
return parseUnknown(callHistoryDetailsSchema, row as unknown);
}
function getConversationIdForCallHistory(
@ -4110,7 +4111,7 @@ function getCallHistoryGroupsCount(
return 0;
}
return countSchema.parse(result);
return parseUnknown(countSchema, result as unknown);
}
const groupsDataSchema = z.array(
@ -4135,7 +4136,8 @@ function getCallHistoryGroups(
// getCallHistoryGroupData creates a temporary table and thus requires
// write access.
const writable = toUnsafeWritableDB(db, 'only temp table use');
const groupsData = groupsDataSchema.parse(
const groupsData = parseUnknown(
groupsDataSchema,
getCallHistoryGroupData(writable, false, filter, pagination)
);
@ -4145,8 +4147,9 @@ function getCallHistoryGroups(
.map(groupData => {
return {
...groupData,
possibleChildren: possibleChildrenSchema.parse(
JSON.parse(groupData.possibleChildren)
possibleChildren: parseUnknown(
possibleChildrenSchema,
JSON.parse(groupData.possibleChildren) as unknown
),
inPeriod: new Set(groupData.inPeriod.split(',')),
};
@ -4167,7 +4170,7 @@ function getCallHistoryGroups(
}
}
return callHistoryGroupSchema.parse({ ...rest, type, children });
return parseStrict(callHistoryGroupSchema, { ...rest, type, children });
})
.reverse();
}
@ -4788,14 +4791,14 @@ function getAttachmentDownloadJob(
function removeAllBackupAttachmentDownloadJobs(db: WritableDB): void {
const [query, params] = sql`
DELETE FROM attachment_downloads
DELETE FROM attachment_downloads
WHERE source = ${AttachmentDownloadSource.BACKUP_IMPORT};`;
db.prepare(query).run(params);
}
function getSizeOfPendingBackupAttachmentDownloadJobs(db: ReadableDB): number {
const [query, params] = sql`
SELECT SUM(ciphertextSize) FROM attachment_downloads
SELECT SUM(ciphertextSize) FROM attachment_downloads
WHERE source = ${AttachmentDownloadSource.BACKUP_IMPORT};`;
return db.prepare(query).pluck().get(params);
}
@ -4842,7 +4845,7 @@ function getNextAttachmentDownloadJobs(
})
AND
messageId IN (${sqlJoin(prioritizeMessageIds)})
AND
AND
${sourceWhereFragment}
-- for priority messages, let's load them oldest first; this helps, e.g. for stories where we
-- want the oldest one first
@ -4862,7 +4865,7 @@ function getNextAttachmentDownloadJobs(
active = 0
AND
(retryAfter is NULL OR retryAfter <= ${timestamp})
AND
AND
${sourceWhereFragment}
ORDER BY receivedAt DESC
LIMIT ${numJobsRemaining}
@ -4876,14 +4879,14 @@ function getNextAttachmentDownloadJobs(
try {
return allJobs.map(row => {
try {
return attachmentDownloadJobSchema.parse({
return parseUnknown(attachmentDownloadJobSchema, {
...row,
active: Boolean(row.active),
attachment: jsonToObject(row.attachmentJson),
ciphertextSize:
row.ciphertextSize ||
getAttachmentCiphertextLength(row.attachment.size),
});
} as unknown);
} catch (error) {
logger.error(
`getNextAttachmentDownloadJobs: Error with job for message ${row.messageId}, deleting.`
@ -5040,11 +5043,11 @@ function getNextAttachmentBackupJobs(
const rows = db.prepare(query).all(params);
return rows
.map(row => {
const parseResult = attachmentBackupJobSchema.safeParse({
const parseResult = safeParseUnknown(attachmentBackupJobSchema, {
...row,
active: Boolean(row.active),
data: jsonToObject(row.data),
});
} as unknown);
if (!parseResult.success) {
const redactedMediaName = redactGenericText(row.mediaName);
logger.error(

View file

@ -12,6 +12,7 @@ import {
import type { AttachmentType } from '../../types/Attachment';
import { jsonToObject, objectToJSON, sql } from '../util';
import { AttachmentDownloadSource } from '../Interface';
import { parsePartial } from '../../util/schemas';
export const version = 1040;
@ -68,7 +69,7 @@ export function updateToSchemaVersion1040(
attempts INTEGER NOT NULL,
retryAfter INTEGER,
lastAttemptTimestamp INTEGER,
PRIMARY KEY (messageId, attachmentType, digest)
) STRICT;
`);
@ -84,7 +85,7 @@ export function updateToSchemaVersion1040(
// 5. Add new index on active & receivedAt. For most queries when there are lots of
// jobs (like during backup restore), many jobs will match the the WHERE clause, so
// the ORDER BY on receivedAt is probably the most expensive part.
db.exec(`
db.exec(`
CREATE INDEX attachment_downloads_active_receivedAt
ON attachment_downloads (
active, receivedAt
@ -94,7 +95,7 @@ export function updateToSchemaVersion1040(
// 6. Add new index on active & messageId. In order to prioritize visible messages,
// we'll also query for rows with a matching messageId. For these, the messageId
// matching is likely going to be the most expensive part.
db.exec(`
db.exec(`
CREATE INDEX attachment_downloads_active_messageId
ON attachment_downloads (
active, messageId
@ -103,7 +104,7 @@ export function updateToSchemaVersion1040(
// 7. Add new index just on messageId, for the ON DELETE CASCADE foreign key
// constraint
db.exec(`
db.exec(`
CREATE INDEX attachment_downloads_messageId
ON attachment_downloads (
messageId
@ -139,7 +140,7 @@ export function updateToSchemaVersion1040(
ciphertextSize: 0,
};
const parsed = attachmentDownloadJobSchema.parse(updatedJob);
const parsed = parsePartial(attachmentDownloadJobSchema, updatedJob);
rowsToTransfer.push(parsed as AttachmentDownloadJobType);
} catch {
@ -160,13 +161,13 @@ export function updateToSchemaVersion1040(
(
messageId,
attachmentType,
receivedAt,
receivedAt,
sentAt,
digest,
contentType,
size,
attachmentJson,
active,
active,
attempts,
retryAfter,
lastAttemptTimestamp
@ -181,7 +182,7 @@ export function updateToSchemaVersion1040(
${row.contentType},
${row.size},
${objectToJSON(row.attachment)},
${row.active ? 1 : 0},
${row.active ? 1 : 0},
${row.attempts},
${row.retryAfter},
${row.lastAttemptTimestamp}

View file

@ -23,6 +23,7 @@ import type { WritableDB, MessageType, ConversationType } from '../Interface';
import { strictAssert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { isAciString } from '../../util/isAciString';
import { safeParseStrict } from '../../util/schemas';
// Legacy type for calls that never had a call id
type DirectCallHistoryDetailsType = {
@ -177,7 +178,7 @@ function convertLegacyCallDetails(
endedTimestamp: null,
};
const result = callHistoryDetailsSchema.safeParse(callHistory);
const result = safeParseStrict(callHistoryDetailsSchema, callHistory);
if (result.success) {
return result.data;
}

View file

@ -21,6 +21,7 @@ import { prepare } from '../Server';
import { sql } from '../util';
import { strictAssert } from '../../util/assert';
import { CallStatusValue } from '../../types/CallDisposition';
import { parseStrict, parseUnknown } from '../../util/schemas';
export function callLinkExists(db: ReadableDB, roomId: string): boolean {
const [query, params] = sql`
@ -58,7 +59,7 @@ export function getCallLinkRecordByRoomId(
return undefined;
}
return callLinkRecordSchema.parse(row);
return parseUnknown(callLinkRecordSchema, row as unknown);
}
export function getAllCallLinks(db: ReadableDB): ReadonlyArray<CallLinkType> {
@ -68,7 +69,9 @@ export function getAllCallLinks(db: ReadableDB): ReadonlyArray<CallLinkType> {
return db
.prepare(query)
.all()
.map(item => callLinkFromRecord(callLinkRecordSchema.parse(item)));
.map((item: unknown) =>
callLinkFromRecord(parseUnknown(callLinkRecordSchema, item))
);
}
function _insertCallLink(db: WritableDB, callLink: CallLinkType): void {
@ -142,7 +145,10 @@ export function updateCallLinkState(
callLinkState: CallLinkStateType
): CallLinkType {
const { name, restrictions, expiration, revoked } = callLinkState;
const restrictionsValue = callLinkRestrictionsSchema.parse(restrictions);
const restrictionsValue = parseStrict(
callLinkRestrictionsSchema,
restrictions
);
const [query, params] = sql`
UPDATE callLinks
SET
@ -153,9 +159,9 @@ export function updateCallLinkState(
WHERE roomId = ${roomId}
RETURNING *;
`;
const row = db.prepare(query).get(params);
const row: unknown = db.prepare(query).get(params);
strictAssert(row, 'Expected row to be returned');
return callLinkFromRecord(callLinkRecordSchema.parse(row));
return callLinkFromRecord(parseUnknown(callLinkRecordSchema, row));
}
export function updateCallLinkAdminKeyByRoomId(
@ -302,7 +308,7 @@ export function getAllCallLinkRecordsWithAdminKey(
return db
.prepare(query)
.all()
.map(item => callLinkRecordSchema.parse(item));
.map((item: unknown) => parseUnknown(callLinkRecordSchema, item));
}
export function getAllMarkedDeletedCallLinkRoomIds(

View file

@ -8,7 +8,6 @@ import type {
} from '../../types/GroupSendEndorsements';
import {
groupSendEndorsementExpirationSchema,
groupSendCombinedEndorsementSchema,
groupSendMemberEndorsementSchema,
groupSendEndorsementsDataSchema,
} from '../../types/GroupSendEndorsements';
@ -17,6 +16,7 @@ import type { ReadableDB, WritableDB } from '../Interface';
import { sql } from '../util';
import type { AciString } from '../../types/ServiceId';
import { strictAssert } from '../../util/assert';
import { parseLoose, parseUnknown } from '../../util/schemas';
/**
* We don't need to store more than one endorsement per group or per member.
@ -110,7 +110,7 @@ export function getGroupSendCombinedEndorsementExpiration(
if (value == null) {
return null;
}
return groupSendEndorsementExpirationSchema.parse(value);
return parseUnknown(groupSendEndorsementExpirationSchema, value as unknown);
}
export function getGroupSendEndorsementsData(
@ -128,24 +128,21 @@ export function getGroupSendEndorsementsData(
WHERE groupId IS ${groupId}
`;
const combinedEndorsement = groupSendCombinedEndorsementSchema
.optional()
.parse(
prepare<Array<unknown>>(db, selectCombinedEndorsement).get(
selectCombinedEndorsementParams
)
);
const combinedEndorsement: unknown = prepare<Array<unknown>>(
db,
selectCombinedEndorsement
).get(selectCombinedEndorsementParams);
if (combinedEndorsement == null) {
return null;
}
const memberEndorsements = prepare<Array<unknown>>(
const memberEndorsements: Array<unknown> = prepare<Array<unknown>>(
db,
selectMemberEndorsements
).all(selectMemberEndorsementsParams);
return groupSendEndorsementsDataSchema.parse({
return parseLoose(groupSendEndorsementsDataSchema, {
combinedEndorsement,
memberEndorsements,
});
@ -168,5 +165,5 @@ export function getGroupSendMemberEndorsement(
if (row == null) {
return null;
}
return groupSendMemberEndorsementSchema.parse(row);
return parseUnknown(groupSendMemberEndorsementSchema, row as unknown);
}

View file

@ -20,6 +20,7 @@ import type { JOB_STATUS } from '../../jobs/JobQueue';
import { JobQueue } from '../../jobs/JobQueue';
import type { ParsedJob, StoredJob, JobQueueStore } from '../../jobs/types';
import { sleep } from '../../util/sleep';
import { parseUnknown } from '../../util/schemas';
describe('JobQueue', () => {
describe('end-to-end tests', () => {
@ -36,7 +37,7 @@ describe('JobQueue', () => {
class Queue extends JobQueue<TestJobData> {
parseData(data: unknown): TestJobData {
return testJobSchema.parse(data);
return parseUnknown(testJobSchema, data);
}
async run({
@ -86,7 +87,7 @@ describe('JobQueue', () => {
class Queue extends JobQueue<number> {
parseData(data: unknown): number {
return z.number().parse(data);
return parseUnknown(z.number(), data);
}
async run(): Promise<typeof JOB_STATUS.NEEDS_RETRY | undefined> {
@ -137,7 +138,7 @@ describe('JobQueue', () => {
class Queue extends JobQueue<number> {
parseData(data: unknown): number {
return z.number().parse(data);
return parseUnknown(z.number(), data);
}
protected override getInMemoryQueue(
@ -180,7 +181,7 @@ describe('JobQueue', () => {
class TestQueue extends JobQueue<string> {
parseData(data: unknown): string {
return z.string().parse(data);
return parseUnknown(z.string(), data);
}
async run(): Promise<typeof JOB_STATUS.NEEDS_RETRY | undefined> {
@ -248,7 +249,7 @@ describe('JobQueue', () => {
class TestQueue extends JobQueue<string> {
parseData(data: unknown): string {
return z.string().parse(data);
return parseUnknown(z.string(), data);
}
async run(): Promise<typeof JOB_STATUS.NEEDS_RETRY | undefined> {
@ -353,7 +354,6 @@ describe('JobQueue', () => {
// Chai's `assert.instanceOf` doesn't tell TypeScript anything, so we do it here.
if (!(booErr instanceof JobError)) {
assert.fail('Expected error to be a JobError');
return;
}
assert.include(booErr.message, 'bar job always fails in this test');
@ -367,7 +367,7 @@ describe('JobQueue', () => {
class TestQueue extends JobQueue<string> {
parseData(data: unknown): string {
return z.string().parse(data);
return parseUnknown(z.string(), data);
}
async run(
@ -412,7 +412,7 @@ describe('JobQueue', () => {
class TestQueue extends JobQueue<number> {
parseData(data: unknown): number {
return z.number().parse(data);
return parseUnknown(z.number(), data);
}
async run(
@ -490,7 +490,6 @@ describe('JobQueue', () => {
// Chai's `assert.instanceOf` doesn't tell TypeScript anything, so we do it here.
if (!(jobError instanceof JobError)) {
assert.fail('Expected error to be a JobError');
return;
}
assert.include(
jobError.message,
@ -740,7 +739,7 @@ describe('JobQueue', () => {
while (true) {
// eslint-disable-next-line no-await-in-loop
const [job] = await once(this.eventEmitter, 'drip');
yield storedJobSchema.parse(job);
yield parseUnknown(storedJobSchema, job as unknown);
}
}
@ -766,7 +765,7 @@ describe('JobQueue', () => {
class TestQueue extends JobQueue<number> {
parseData(data: unknown): number {
return z.number().parse(data);
return parseUnknown(z.number(), data);
}
async run({

View file

@ -22,6 +22,7 @@ import { getCallIdFromEra } from '../../util/callDisposition';
import { isValidUuid } from '../../util/isValidUuid';
import { createDB, updateToVersion } from './helpers';
import type { WritableDB, MessageType } from '../../sql/Interface';
import { parsePartial } from '../../util/schemas';
describe('SQL/updateToSchemaVersion89', () => {
let db: WritableDB;
@ -152,8 +153,8 @@ describe('SQL/updateToSchemaVersion89', () => {
return db
.prepare(selectHistoryQuery)
.all()
.map(row => {
return callHistoryDetailsSchema.parse({
.map((row: object) => {
return parsePartial(callHistoryDetailsSchema, {
...row,
// Not present at the time of migration, but required by zod

View file

@ -0,0 +1,242 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import assert from 'node:assert/strict';
import {
parseLoose,
parsePartial,
parseStrict,
parseUnknown,
SchemaParseError,
} from '../../util/schemas';
describe('schemas', () => {
const schema = z.object({ prop: z.literal('value') });
it('rejects invalid inputs', () => {
function assertThrows(fn: () => void) {
assert.throws(fn, SchemaParseError);
}
const input = { prop: 42 };
// @ts-expect-error: not unknown
assertThrows(() => parseUnknown(schema, input));
// @ts-expect-error: invalid type
assertThrows(() => parseStrict(schema, input));
assertThrows(() => parseLoose(schema, input));
// @ts-expect-error: invalid type
assertThrows(() => parsePartial(schema, input));
});
it('accepts valid inputs', () => {
const valid = { prop: 'value' };
function assertShape(value: { prop: 'value' }) {
assert.deepEqual(value, valid);
}
// unknown
{
const input = valid as unknown;
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// @ts-expect-error: not loose
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// any
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const input = valid as unknown as any;
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// @ts-expect-error: not loose
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// {}
{
// eslint-disable-next-line @typescript-eslint/ban-types
const input = valid as unknown as {};
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// @ts-expect-error: not loose
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// never
{
const input = valid as unknown as never;
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// @ts-expect-error: not loose
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// { prop: "value" }
{
const input = valid as { prop: 'value' };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop?: "value" }
{
const input = valid as { prop?: 'value' };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// @ts-expect-error: not loose
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: "value" | void }
{
const input = valid as { prop: 'value' | void };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: "value" | undefined }
{
const input = valid as { prop: 'value' | undefined };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: "value" | null }
{
const input = valid as { prop: 'value' | null };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: string }
{
const input = valid as { prop: string };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// { prop?: string }
{
const input = valid as { prop?: string };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// @ts-expect-error: not loose
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// { prop: void }
{
const input = valid as unknown as { prop: void };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: undefined }
{
const input = valid as unknown as { prop: undefined };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: null }
{
const input = valid as unknown as { prop: null };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
assertShape(parsePartial(schema, input));
}
// { prop: unknown }
{
const input = valid as { prop: unknown };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// { prop: any }
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const input = valid as { prop: any };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// (ideally not allowed)
assertShape(parseStrict(schema, input));
// (ideally not allowed)
assertShape(parseLoose(schema, input));
// (ideally not allowed)
assertShape(parsePartial(schema, input));
}
// { prop: {} }
{
// eslint-disable-next-line @typescript-eslint/ban-types
const input = valid as { prop: {} };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// @ts-expect-error: not strict
assertShape(parseStrict(schema, input));
// (ideally not allowed)
assertShape(parseLoose(schema, input));
// @ts-expect-error: not partial
assertShape(parsePartial(schema, input));
}
// { prop: never }
{
const input = valid as { prop: never };
// @ts-expect-error: not unknown
assertShape(parseUnknown(schema, input));
// (ideally not allowed)
assertShape(parseStrict(schema, input));
// (ideally not allowed)
assertShape(parseLoose(schema, input));
// (ideally not allowed)
assertShape(parsePartial(schema, input));
}
});
});

View file

@ -73,6 +73,7 @@ import { safeParseNumber } from '../util/numbers';
import { isStagingServer } from '../util/isStagingServer';
import type { IWebSocketResource } from './WebsocketResources';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
import { parseUnknown, safeParseUnknown } from '../util/schemas';
// Note: this will break some code that expects to be able to use err.response when a
// web request fails, because it will force it to text. But it is very useful for
@ -1948,7 +1949,7 @@ export function initialize({
responseType: 'json',
});
return whoamiResultZod.parse(response);
return parseUnknown(whoamiResultZod, response);
}
async function sendChallengeResponse(challengeResponse: ChallengeType) {
@ -2027,7 +2028,7 @@ export function initialize({
httpType: 'GET',
responseType: 'json',
});
const res = remoteConfigResponseZod.parse(rawRes);
const res = parseUnknown(remoteConfigResponseZod, rawRes);
return {
...res,
@ -2157,7 +2158,7 @@ export function initialize({
responseType: 'json',
});
const result = verifyServiceIdResponse.safeParse(res);
const result = safeParseUnknown(verifyServiceIdResponse, res);
if (result.success) {
return result.data;
@ -2223,7 +2224,8 @@ export function initialize({
hash,
}: GetAccountForUsernameOptionsType) {
const hashBase64 = toWebSafeBase64(Bytes.toBase64(hash));
return getAccountForUsernameResultZod.parse(
return parseUnknown(
getAccountForUsernameResultZod,
await _ajax({
call: 'username',
httpType: 'GET',
@ -2251,7 +2253,7 @@ export function initialize({
return;
}
return uploadAvatarHeadersZod.parse(res);
return parseUnknown(uploadAvatarHeadersZod, res as unknown);
}
async function getProfileUnauth(
@ -2389,7 +2391,7 @@ export function initialize({
abortSignal,
});
return reserveUsernameResultZod.parse(response);
return parseUnknown(reserveUsernameResultZod, response);
}
async function confirmUsername({
hash,
@ -2408,14 +2410,15 @@ export function initialize({
responseType: 'json',
abortSignal,
});
return confirmUsernameResultZod.parse(response);
return parseUnknown(confirmUsernameResultZod, response);
}
async function replaceUsernameLink({
encryptedUsername,
keepLinkHandle,
}: ReplaceUsernameLinkOptionsType): Promise<ReplaceUsernameLinkResultType> {
return replaceUsernameLinkResultZod.parse(
return parseUnknown(
replaceUsernameLinkResultZod,
await _ajax({
call: 'usernameLink',
httpType: 'PUT',
@ -2440,7 +2443,8 @@ export function initialize({
async function resolveUsernameLink(
serverId: string
): Promise<ResolveUsernameLinkResultType> {
return resolveUsernameLinkResultZod.parse(
return parseUnknown(
resolveUsernameLinkResultZod,
await _ajax({
httpType: 'GET',
call: 'usernameLink',
@ -2475,7 +2479,8 @@ export function initialize({
transport: VerificationTransport
) {
// Create a new blank session using just a E164
let session = verificationSessionZod.parse(
let session = parseUnknown(
verificationSessionZod,
await _ajax({
call: 'verificationSession',
httpType: 'POST',
@ -2490,7 +2495,8 @@ export function initialize({
);
// Submit a captcha solution to the session
session = verificationSessionZod.parse(
session = parseUnknown(
verificationSessionZod,
await _ajax({
call: 'verificationSession',
httpType: 'PATCH',
@ -2511,7 +2517,8 @@ export function initialize({
}
// Request an SMS or Voice confirmation
session = verificationSessionZod.parse(
session = parseUnknown(
verificationSessionZod,
await _ajax({
call: 'verificationSession',
httpType: 'POST',
@ -2618,7 +2625,8 @@ export function initialize({
aciPqLastResortPreKey,
pniPqLastResortPreKey,
}: CreateAccountOptionsType) {
const session = verificationSessionZod.parse(
const session = parseUnknown(
verificationSessionZod,
await _ajax({
isRegistration: true,
call: 'verificationSession',
@ -2676,7 +2684,7 @@ export function initialize({
jsonData,
});
return createAccountResultZod.parse(responseJson);
return parseUnknown(createAccountResultZod, responseJson);
}
);
}
@ -2726,7 +2734,7 @@ export function initialize({
jsonData,
});
return linkDeviceResultZod.parse(responseJson);
return parseUnknown(linkDeviceResultZod, responseJson);
}
);
}
@ -2842,7 +2850,7 @@ export function initialize({
responseType: 'json',
});
return getBackupInfoResponseSchema.parse(res);
return parseUnknown(getBackupInfoResponseSchema, res);
}
async function getBackupStream({
@ -2880,7 +2888,7 @@ export function initialize({
responseType: 'json',
});
return attachmentUploadFormResponse.parse(res);
return parseUnknown(attachmentUploadFormResponse, res);
}
function createFetchForAttachmentUpload({
@ -2932,7 +2940,7 @@ export function initialize({
responseType: 'json',
});
return attachmentUploadFormResponse.parse(res);
return parseUnknown(attachmentUploadFormResponse, res);
}
async function refreshBackup(headers: BackupPresentationHeadersType) {
@ -2961,7 +2969,7 @@ export function initialize({
responseType: 'json',
});
return getBackupCredentialsResponseSchema.parse(res);
return parseUnknown(getBackupCredentialsResponseSchema, res);
}
async function getBackupCDNCredentials({
@ -2979,7 +2987,7 @@ export function initialize({
responseType: 'json',
});
return getBackupCDNCredentialsResponseSchema.parse(res);
return parseUnknown(getBackupCDNCredentialsResponseSchema, res);
}
async function setBackupId({
@ -3051,7 +3059,7 @@ export function initialize({
},
});
return backupMediaBatchResponseSchema.parse(res);
return parseUnknown(backupMediaBatchResponseSchema, res);
}
async function backupDeleteMedia({
@ -3099,7 +3107,7 @@ export function initialize({
urlParameters: `?${params.join('&')}`,
});
return backupListMediaResponseSchema.parse(res);
return parseUnknown(backupListMediaResponseSchema, res);
}
async function callLinkCreateAuth(
@ -3111,7 +3119,7 @@ export function initialize({
responseType: 'json',
jsonData: { createCallLinkCredentialRequest: requestBase64 },
});
return callLinkCreateAuthResponseSchema.parse(response);
return parseUnknown(callLinkCreateAuthResponseSchema, response);
}
async function setPhoneNumberDiscoverability(newValue: boolean) {
@ -3354,7 +3362,10 @@ export function initialize({
accessKey: accessKeys != null ? Bytes.toBase64(accessKeys) : undefined,
groupSendToken,
});
const parseResult = multiRecipient200ResponseSchema.safeParse(response);
const parseResult = safeParseUnknown(
multiRecipient200ResponseSchema,
response
);
if (parseResult.success) {
return parseResult.data;
}
@ -3490,8 +3501,10 @@ export function initialize({
urlParameters: `/${encryptedStickers.length}`,
});
const { packId, manifest, stickers } =
StickerPackUploadFormSchema.parse(formJson);
const { packId, manifest, stickers } = parseUnknown(
StickerPackUploadFormSchema,
formJson
);
// Upload manifest
const manifestParams = makePutParams(manifest, encryptedManifest);
@ -3718,7 +3731,8 @@ export function initialize({
}
async function getAttachmentUploadForm() {
return attachmentUploadFormResponse.parse(
return parseUnknown(
attachmentUploadFormResponse,
await _ajax({
call: 'attachmentUploadForm',
httpType: 'GET',

View file

@ -62,6 +62,7 @@ import { ToastType } from '../types/Toast';
import { AbortableProcess } from '../util/AbortableProcess';
import type { WebAPICredentials } from './Types';
import { NORMAL_DISCONNECT_CODE } from './SocketManager';
import { parseUnknown } from '../util/schemas';
const THIRTY_SECONDS = 30 * durations.SECOND;
@ -107,7 +108,7 @@ export namespace AggregatedStats {
try {
const json = localStorage.getItem(key);
return json != null
? AggregatedStatsSchema.parse(JSON.parse(json))
? parseUnknown(AggregatedStatsSchema, JSON.parse(json) as unknown)
: createEmpty();
} catch (error) {
log.warn(

View file

@ -6,6 +6,7 @@ import type { ConversationType } from '../state/ducks/conversations';
import { safeParseInteger } from '../util/numbers';
import { byteLength } from '../Bytes';
import type { StorageServiceFieldsType } from '../sql/Interface';
import { parsePartial } from '../util/schemas';
export enum CallLinkUpdateSyncType {
Update = 'Update',
@ -44,7 +45,10 @@ export const callLinkRestrictionsSchema = z.nativeEnum(CallLinkRestrictions);
export function toCallLinkRestrictions(
restrictions: number | string
): CallLinkRestrictions {
return callLinkRestrictionsSchema.parse(safeParseInteger(restrictions));
return parsePartial(
callLinkRestrictionsSchema,
safeParseInteger(restrictions)
);
}
/**

View file

@ -3,6 +3,7 @@
import { z } from 'zod';
import { aciSchema, type AciString } from './ServiceId';
import * as Bytes from '../Bytes';
import { parseStrict } from '../util/schemas';
const GROUPV2_ID_LENGTH = 32; // 32 bytes
@ -94,5 +95,5 @@ export const groupSendTokenSchema = z
export type GroupSendToken = z.infer<typeof groupSendTokenSchema>;
export function toGroupSendToken(token: Uint8Array): GroupSendToken {
return groupSendTokenSchema.parse(token);
return parseStrict(groupSendTokenSchema, token);
}

View file

@ -68,6 +68,7 @@ import { drop } from './drop';
import { sendCallLinkUpdateSync } from './sendCallLinkUpdateSync';
import { storageServiceUploadJob } from '../services/storage';
import { CallLinkDeleteManager } from '../jobs/CallLinkDeleteManager';
import { parsePartial, parseStrict } from './schemas';
// utils
// -----
@ -200,7 +201,7 @@ export function getCallEventForProto(
callEventProto: Proto.SyncMessage.ICallEvent,
eventSource: string
): CallEventDetails {
const callEvent = callEventNormalizeSchema.parse(callEventProto);
const callEvent = parsePartial(callEventNormalizeSchema, callEventProto);
const { callId, peerId, timestamp } = callEvent;
let type: CallType;
@ -251,7 +252,7 @@ export function getCallEventForProto(
throw new TypeError(`Unknown call event ${callEvent.event}`);
}
return callEventDetailsSchema.parse({
return parseStrict(callEventDetailsSchema, {
callId,
peerId,
ringerId: null,
@ -279,7 +280,10 @@ const callLogEventFromProto: Partial<
export function getCallLogEventForProto(
callLogEventProto: Proto.SyncMessage.ICallLogEvent
): CallLogEventDetails {
const callLogEvent = callLogEventNormalizeSchema.parse(callLogEventProto);
const callLogEvent = parsePartial(
callLogEventNormalizeSchema,
callLogEventProto
);
const type = callLogEventFromProto[callLogEvent.type];
if (type == null) {
@ -496,7 +500,7 @@ export function getCallDetailsFromDirectCall(
call: Call
): CallDetails {
const ringerId = call.isIncoming ? call.remoteUserId : null;
return callDetailsSchema.parse({
return parseStrict(callDetailsSchema, {
callId: Long.fromValue(call.callId).toString(),
peerId,
ringerId,
@ -518,7 +522,7 @@ export function getCallDetailsFromEndedDirectCall(
wasVideoCall: boolean,
timestamp: number
): CallDetails {
return callDetailsSchema.parse({
return parseStrict(callDetailsSchema, {
callId,
peerId,
ringerId,
@ -535,7 +539,7 @@ export function getCallDetailsFromGroupCallMeta(
peerId: AciString | string,
groupCallMeta: GroupCallMeta
): CallDetails {
return callDetailsSchema.parse({
return parseStrict(callDetailsSchema, {
callId: groupCallMeta.callId,
peerId,
ringerId: groupCallMeta.ringerId,
@ -552,7 +556,7 @@ export function getCallDetailsForAdhocCall(
peerId: AciString | string,
callId: string
): CallDetails {
return callDetailsSchema.parse({
return parseStrict(callDetailsSchema, {
callId,
peerId,
ringerId: null,
@ -575,7 +579,11 @@ export function getCallEventDetails(
event: LocalCallEvent,
eventSource: string
): CallEventDetails {
return callEventDetailsSchema.parse({ ...callDetails, event, eventSource });
return parseStrict(callEventDetailsSchema, {
...callDetails,
event,
eventSource,
});
}
// transitions
@ -646,7 +654,7 @@ export function transitionCallHistory(
`transitionCallHistory: Transitioned call history timestamp (before: ${callHistory?.timestamp}, after: ${timestamp})`
);
return callHistoryDetailsSchema.parse({
return parseStrict(callHistoryDetailsSchema, {
callId,
peerId,
ringerId,

View file

@ -33,6 +33,7 @@ import {
getKeyFromCallLink,
toAdminKeyBytes,
} from './callLinks';
import { parseStrict } from './schemas';
/**
* RingRTC conversions
@ -56,7 +57,7 @@ const RingRTCCallLinkRestrictionsSchema = z.nativeEnum(
export function callLinkRestrictionsToRingRTC(
restrictions: CallLinkRestrictions
): RingRTCCallLinkRestrictions {
return RingRTCCallLinkRestrictionsSchema.parse(restrictions);
return parseStrict(RingRTCCallLinkRestrictionsSchema, restrictions);
}
export function getRoomIdFromRootKey(rootKey: CallLinkRootKey): string {
@ -152,7 +153,7 @@ export function callLinkToRecord(callLink: CallLinkType): CallLinkRecord {
const adminKey = callLink.adminKey
? toAdminKeyBytes(callLink.adminKey)
: null;
return callLinkRecordSchema.parse({
return parseStrict(callLinkRecordSchema, {
roomId: callLink.roomId,
rootKey,
adminKey,

View file

@ -27,6 +27,7 @@ import { ToastType } from '../types/Toast';
import * as Errors from '../types/errors';
import { isTestOrMockEnvironment } from '../environment';
import { isAlpha } from './version';
import { parseStrict } from './schemas';
export function decodeGroupSendEndorsementResponse({
groupId,
@ -91,7 +92,7 @@ export function decodeGroupSendEndorsementResponse({
`decodeGroupSendEndorsementResponse: Received endorsements (group: ${idForLogging}, expiration: ${expiration}, members: ${groupMembers.length})`
);
const groupEndorsementsData: GroupSendEndorsementsData = {
return parseStrict(groupSendEndorsementsDataSchema, {
combinedEndorsement: {
groupId,
expiration,
@ -110,9 +111,7 @@ export function decodeGroupSendEndorsementResponse({
endorsement: endorsement.getContents(),
};
}),
};
return groupSendEndorsementsDataSchema.parse(groupEndorsementsData);
});
}
const TWO_DAYS = DurationInSeconds.fromDays(2);

View file

@ -5,6 +5,7 @@ import { z } from 'zod';
import { groupBy } from 'lodash';
import * as log from '../logging/log';
import { aciSchema } from '../types/ServiceId';
import { safeParseStrict } from './schemas';
const retryItemSchema = z
.object({
@ -53,7 +54,8 @@ export class RetryPlaceholders {
);
}
const parsed = retryItemListSchema.safeParse(
const parsed = safeParseStrict(
retryItemListSchema,
window.storage.get(STORAGE_KEY, new Array<RetryItemType>())
);
if (!parsed.success) {
@ -104,7 +106,7 @@ export class RetryPlaceholders {
// Basic data management
async add(item: RetryItemType): Promise<void> {
const parsed = retryItemSchema.safeParse(item);
const parsed = safeParseStrict(retryItemSchema, item);
if (!parsed.success) {
throw new Error(
`RetryPlaceholders.add: Item did not match schema ${JSON.stringify(

179
ts/util/schemas.ts Normal file
View file

@ -0,0 +1,179 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
IfAny,
IfEmptyObject,
IfNever,
IfUnknown,
IsLiteral,
LiteralToPrimitive,
Primitive,
} from 'type-fest';
import type { SafeParseReturnType, ZodError, ZodType, ZodTypeDef } from 'zod';
type Schema<Input, Output> = ZodType<Output, ZodTypeDef, Input>;
type SafeResult<Output> = SafeParseReturnType<unknown, Output>;
type LooseInput<T> =
IsLiteral<T> extends true ? LiteralToPrimitive<T> : Record<keyof T, unknown>;
type PartialInput<T> = T extends Primitive
? T | null | void
: { [Key in keyof T]?: T[Key] | null | void };
export class SchemaParseError extends TypeError {
constructor(schema: Schema<unknown, unknown>, error: ZodError<unknown>) {
let message = 'zod: issues found when parsing with schema';
if (schema.description) {
message += ` (${schema.description})`;
}
message += ':';
for (const issue of error.issues) {
message += `\n - ${issue.path.join('.')}: ${issue.message}`;
}
super(message);
}
}
function parse<Output>(
schema: Schema<unknown, Output>,
input: unknown
): Output {
const result = schema.safeParse(input);
if (result.success) {
return result.data;
}
throw new SchemaParseError(schema, result.error);
}
function safeParse<Output>(
schema: Schema<unknown, Output>,
input: unknown
): SafeResult<Output> {
return schema.safeParse(input);
}
/**
* This uses type-fest to validate that the data being passed into parse() and
* safeParse() is not types like `any`, `{}`, `never`, or an unexpected `unknown`.
*
* `never` is hard to prevent from being passed in, so instead we make the function
* arguments themselves not constructable using an intersection with a warning.
*/
// Must be exactly `unknown`
type UnknownArgs<Data> =
IfAny<Data> extends true
? [data: Data] & 'Unexpected input `any` must be `unknown`'
: IfNever<Data> extends true
? [data: Data] & 'Unexpected input `never` must be `unknown`'
: IfEmptyObject<Data> extends true
? [data: Data] & 'Unexpected input `{}` must be `unknown`'
: IfUnknown<Data> extends true
? [data: Data]
: [data: Data] & 'Unexpected input type must be `unknown`';
type TypedArgs<Data> =
IfAny<Data> extends true
? [data: Data] & 'Unexpected input `any` must be typed'
: IfNever<Data> extends true
? [data: Data] & 'Unexpected input `never` must be typed'
: IfEmptyObject<Data> extends true
? [data: Data] & 'Unexpected input `{}` must be typed'
: IfUnknown<Data> extends true
? [data: Data] & 'Unexpected input `unknown` must be typed'
: [data: Data];
// prettier-ignore
type ParseUnknown = <Input, Output, Data>(schema: Schema<Input, Output>, ...args: UnknownArgs<Data>) => Output;
// prettier-ignore
type SafeParseUnknown = <Input, Output, Data>(schema: Schema<Input, Output>, ...args: UnknownArgs<Data>) => SafeResult<Output>;
// prettier-ignore
type ParseStrict = <Input, Output, Data extends Input>(schema: Schema<Input, Output>, ...args: TypedArgs<Data>) => Output;
// prettier-ignore
type SafeParseStrict = <Input, Output, Data extends Input>(schema: Schema<Input, Output>, ...args: TypedArgs<Data>) => SafeResult<Output>;
// prettier-ignore
type ParseLoose = <Input, Output, Data extends LooseInput<Input>>(schema: Schema<Input, Output>, ...args: TypedArgs<Data>) => Output;
// prettier-ignore
type SafeParseLoose = <Input, Output, Data extends LooseInput<Input>>(schema: Schema<Input, Output>, ...args: TypedArgs<Data>) => SafeResult<Output>;
// prettier-ignore
type ParsePartial = <Input, Output, Data extends PartialInput<Input>>(schema: Schema<Input, Output>, ...args: TypedArgs<Data>) => Output;
// prettier-ignore
type SafeParsePartial = <Input, Output, Data extends PartialInput<Input>>(schema: Schema<Input, Output>, ...args: TypedArgs<Data>) => SafeResult<Output>;
/**
* Parse an *unknown* value with a zod schema.
* ```ts
* type Input = unknown // unknown
* type Output = { prop: string }
* ```
* @throws {SchemaParseError}
*/
export const parseUnknown: ParseUnknown = parse;
/**
* Safely parse an *unknown* value with a zod schema.
* ```ts
* type Input = unknown // unknown
* type Output = { success: true, error: null, data: { prop: string } }
* ```
*/
export const safeParseUnknown: SafeParseUnknown = safeParse;
/**
* Parse a *strict* value with a zod schema.
* ```ts
* type Input = { prop: string } // strict
* type Output = { prop: string }
* ```
* @throws {SchemaParseError}
*/
export const parseStrict: ParseStrict = parse;
/**
* Safely parse a *strict* value with a zod schema.
* ```ts
* type Input = { prop: string } // strict
* type Output = { success: true, error: null, data: { prop: string } }
* ```
*/
export const safeParseStrict: SafeParseStrict = safeParse;
/**
* Parse a *loose* value with a zod schema.
* ```ts
* type Input = { prop: unknown } // loose
* type Output = { prop: string }
* ```
* @throws {SchemaParseError}
*/
export const parseLoose: ParseLoose = parse;
/**
* Safely parse a *loose* value with a zod schema.
* ```ts
* type Input = { prop: unknown } // loose
* type Output = { success: true, error: null, data: { prop: string } }
* ```
*/
export const safeParseLoose: SafeParseLoose = safeParse;
/**
* Parse a *partial* value with a zod schema.
* ```ts
* type Input = { prop?: string | null | undefined } // partial
* type Output = { prop: string }
* ```
* @throws {SchemaParseError}
*/
export const parsePartial: ParsePartial = parse;
/**
* Safely parse a *partial* value with a zod schema.
* ```ts
* type Input = { prop?: string | null | undefined } // partial
* type Output = { success: true, error: null, data: { prop: string } }
* ```
*/
export const safeParsePartial: SafeParsePartial = safeParse;

View file

@ -73,6 +73,7 @@ import {
import { maybeUpdateGroup } from '../groups';
import type { GroupSendToken } from '../types/GroupSendEndorsements';
import { isAciString } from './isAciString';
import { safeParseStrict, safeParseUnknown } from './schemas';
const UNKNOWN_RECIPIENT = 404;
const INCORRECT_AUTH_KEY = 401;
@ -603,7 +604,7 @@ export async function sendToGroupViaSenderKey(
{ online, story, urgent }
);
const parsed = multiRecipient200ResponseSchema.safeParse(result);
const parsed = safeParseStrict(multiRecipient200ResponseSchema, result);
if (parsed.success) {
const { uuids404 } = parsed.data;
if (uuids404 && uuids404.length > 0) {
@ -1022,7 +1023,10 @@ async function handle409Response(
error: HTTPError
) {
const logId = sendTarget.idForLogging();
const parsed = multiRecipient409ResponseSchema.safeParse(error.response);
const parsed = safeParseUnknown(
multiRecipient409ResponseSchema,
error.response
);
if (parsed.success) {
await waitForAll({
tasks: parsed.data.map(item => async () => {
@ -1068,7 +1072,10 @@ async function handle410Response(
) {
const logId = sendTarget.idForLogging();
const parsed = multiRecipient410ResponseSchema.safeParse(error.response);
const parsed = safeParseUnknown(
multiRecipient410ResponseSchema,
error.response
);
if (parsed.success) {
await waitForAll({
tasks: parsed.data.map(item => async () => {

View file

@ -8,6 +8,7 @@ import { z } from 'zod';
import { strictAssert } from './assert';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { parsePartial, parseUnknown, safeParseUnknown } from './schemas';
function toUrl(input: URL | string): URL | null {
if (input instanceof URL) {
@ -164,7 +165,10 @@ function _route<Key extends string, Args extends object>(
);
return null;
}
const parseResult = config.schema.safeParse(rawArgs);
const parseResult = safeParseUnknown(
config.schema,
rawArgs as unknown
);
if (parseResult.success) {
const args = parseResult.data;
return {
@ -183,13 +187,13 @@ function _route<Key extends string, Args extends object>(
},
toWebUrl(args) {
if (config.toWebUrl) {
return config.toWebUrl(config.schema.parse(args));
return config.toWebUrl(parseUnknown(config.schema, args as unknown));
}
throw new Error('Route does not support web URLs');
},
toAppUrl(args) {
if (config.toAppUrl) {
return config.toAppUrl(config.schema.parse(args));
return config.toAppUrl(parseUnknown(config.schema, args as unknown));
}
throw new Error('Route does not support app URLs');
},
@ -219,7 +223,7 @@ export const contactByPhoneNumberRoute = _route('contactByPhoneNumber', {
}),
parse(result) {
return {
phoneNumber: paramSchema.parse(result.hash.groups.phoneNumber),
phoneNumber: parsePartial(paramSchema, result.hash.groups.phoneNumber),
};
},
toWebUrl(args) {

View file

@ -30,6 +30,7 @@ import {
onSync as onViewSync,
viewSyncTaskSchema,
} from '../messageModifiers/ViewSyncs';
import { safeParseUnknown } from './schemas';
const syncTaskDataSchema = z.union([
deleteMessageSchema,
@ -86,7 +87,7 @@ export async function queueSyncTasks(
await removeSyncTaskById(id);
return;
}
const parseResult = syncTaskDataSchema.safeParse(data);
const parseResult = safeParseUnknown(syncTaskDataSchema, data);
if (!parseResult.success) {
log.error(
`${innerLogId}: Failed to parse. Deleting. Error: ${parseResult.error}`

View file

@ -20,6 +20,7 @@ import {
} from '../context/localeMessages';
import { waitForSettingsChange } from '../context/waitForSettingsChange';
import { isTestOrMockEnvironment } from '../environment';
import { parseUnknown } from '../util/schemas';
const emojiListCache = new Map<string, LocaleEmojiListType>();
@ -55,8 +56,8 @@ export const MinimalSignalContext: MinimalSignalContextType = {
'OptionalResourceService:getData',
`emoji-index-${locale}.json`
);
const json = JSON.parse(Buffer.from(buf).toString());
const result = LocaleEmojiListSchema.parse(json);
const json: unknown = JSON.parse(Buffer.from(buf).toString());
const result = parseUnknown(LocaleEmojiListSchema, json);
emojiListCache.set(locale, result);
return result;
},