Files
vnidrop/services/diagnostics-api/src/input.ts

594 lines
17 KiB
TypeScript

export type JsonObject = Record<string, unknown>;
export type InputFailure = {
ok: false;
status: 400 | 413 | 415;
error: string;
};
export type InputResult<T> = { ok: true; value: T } | InputFailure;
export type NormalizedProperties = Record<string, string>;
export interface NormalizedEvent {
name: string;
timestampMillis: number;
properties: NormalizedProperties;
schemaVersion: 1;
}
export interface NormalizedBreadcrumb {
name: string;
timestampMillis: number;
properties: NormalizedProperties;
}
export interface NormalizedDevice {
deviceName: string;
deviceModel: string;
operatingSystem: string;
network: string;
batteryLevel: string;
}
export interface NormalizedEventsPayload {
batchId: string;
installId: string;
appVersion: string;
platform: string;
events: NormalizedEvent[];
}
export interface NormalizedCrashPayload {
id: string;
installId: string;
appVersion: string;
platform: string;
exceptionType: string;
exceptionMessage: string;
stackTrace: string;
occurredAt: number;
diagnosticsEnabledAtCapture: boolean;
breadcrumbs: NormalizedBreadcrumb[];
schemaVersion: 1;
}
export interface NormalizedBugPayload {
id: string;
installId: string;
appVersion: string;
platform: string;
occurredAt: number;
whatHappened: string;
expected: string;
steps: string;
contact: string;
logs: string;
device: NormalizedDevice;
breadcrumbs: NormalizedBreadcrumb[];
schemaVersion: 1;
}
export const MAX_LOG_BYTES = 192 * 1024;
export const MAX_BREADCRUMBS_JSON_BYTES = 16_000;
export const MAX_DEVICE_JSON_BYTES = 4_000;
const MAX_PROPERTIES = 12;
const MAX_BREADCRUMBS = 40;
const MISSING = Symbol("missing");
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const UTF8_ENCODER = new TextEncoder();
export async function readJsonObject(
request: Request,
maxBytes: number,
): Promise<InputResult<JsonObject>> {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
throw new RangeError("maxBytes must be a positive safe integer");
}
if (!isApplicationJson(request.headers.get("content-type"))) {
return failure(415, "unsupported_media_type");
}
const contentLength = request.headers.get("content-length");
if (contentLength != null) {
const trimmed = contentLength.trim();
if (!/^\d+$/.test(trimmed)) {
return failure(400, "invalid_content_length");
}
const normalizedLength = trimmed.replace(/^0+(?=\d)/, "");
const maxLength = String(maxBytes);
if (
normalizedLength.length > maxLength.length ||
(normalizedLength.length === maxLength.length && normalizedLength > maxLength)
) {
return failure(413, "payload_too_large");
}
const declaredBytes = Number(trimmed);
if (!Number.isSafeInteger(declaredBytes)) {
return failure(400, "invalid_content_length");
}
}
if (request.body == null) {
return failure(400, "invalid_json");
}
let reader: ReadableStreamDefaultReader<Uint8Array>;
try {
reader = request.body.getReader();
} catch {
return failure(400, "invalid_body");
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
if (chunk.value.byteLength > maxBytes - totalBytes) {
try {
await reader.cancel("payload_too_large");
} catch {
// The size failure remains authoritative if cancellation also fails.
}
return failure(413, "payload_too_large");
}
totalBytes += chunk.value.byteLength;
chunks.push(chunk.value);
}
} catch {
return failure(400, "invalid_body");
} finally {
reader.releaseLock();
}
const bytes = joinChunks(chunks, totalBytes);
let raw: string;
try {
raw = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
} catch {
return failure(400, "invalid_utf8");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return failure(400, "invalid_json");
}
if (!isPlainObject(parsed)) {
return failure(400, "invalid_body");
}
return success(parsed);
}
export function normalizeEvents(
body: JsonObject,
maxEvents = 50,
): InputResult<NormalizedEventsPayload> {
if (!isPlainObject(body)) return failure(400, "invalid_body");
if (!Number.isSafeInteger(maxEvents) || maxEvents <= 0) {
throw new RangeError("maxEvents must be a positive safe integer");
}
const batchId = idField(body, ["batchId", "batch_id"], "invalid_batch_id");
if (!batchId.ok) return batchId;
const installId = installIdField(body);
if (!installId.ok) return installId;
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
if (!appVersion.ok) return appVersion;
const platform = stringField(body, ["platform"], 40, "invalid_platform");
if (!platform.ok) return platform;
const batchSchema = schemaVersion(body);
if (!batchSchema.ok) return batchSchema;
const rawEvents = pick(body, ["events"]);
if (!Array.isArray(rawEvents)) return failure(400, "invalid_events");
if (rawEvents.length === 0) return failure(400, "empty_batch");
if (rawEvents.length > maxEvents) return failure(400, "batch_too_large");
const events: NormalizedEvent[] = [];
for (const rawEvent of rawEvents) {
const event = normalizeEvent(rawEvent);
if (!event.ok) return event;
events.push(event.value);
}
return success({
batchId: batchId.value,
installId: installId.value,
appVersion: appVersion.value,
platform: platform.value,
events,
});
}
export function normalizeCrash(body: JsonObject): InputResult<NormalizedCrashPayload> {
if (!isPlainObject(body)) return failure(400, "invalid_body");
const id = idField(body, ["id"], "invalid_id");
if (!id.ok) return id;
const installId = installIdField(body);
if (!installId.ok) return installId;
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
if (!appVersion.ok) return appVersion;
const platform = stringField(body, ["platform"], 40, "invalid_platform");
if (!platform.ok) return platform;
const exceptionType = stringField(
body,
["exceptionType", "exception_type"],
120,
"invalid_exception_type",
true,
true,
);
if (!exceptionType.ok) return exceptionType;
const exceptionMessage = stringField(
body,
["exceptionMessage", "exception_message"],
2_000,
"invalid_exception_message",
true,
);
if (!exceptionMessage.ok) return exceptionMessage;
const stackTrace = stringField(
body,
["stackTrace", "stack_trace"],
32_000,
"invalid_stack_trace",
true,
);
if (!stackTrace.ok) return stackTrace;
const occurredAt = timestampField(
body,
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
);
if (!occurredAt.ok) return occurredAt;
const diagnosticsEnabled = booleanField(
body,
[
"diagnosticsEnabledAtCapture",
"diagnostics_enabled_at_capture",
"diagnostics_enabled",
],
"invalid_diagnostics_enabled",
true,
);
if (!diagnosticsEnabled.ok) return diagnosticsEnabled;
const version = schemaVersion(body);
if (!version.ok) return version;
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
if (!breadcrumbs.ok) return breadcrumbs;
return success({
id: id.value,
installId: installId.value,
appVersion: appVersion.value,
platform: platform.value,
exceptionType: exceptionType.value,
exceptionMessage: exceptionMessage.value,
stackTrace: stackTrace.value,
occurredAt: occurredAt.value,
diagnosticsEnabledAtCapture: diagnosticsEnabled.value,
breadcrumbs: breadcrumbs.value,
schemaVersion: version.value,
});
}
export function normalizeBug(body: JsonObject): InputResult<NormalizedBugPayload> {
if (!isPlainObject(body)) return failure(400, "invalid_body");
const id = idField(body, ["id"], "invalid_id");
if (!id.ok) return id;
const installId = installIdField(body);
if (!installId.ok) return installId;
const appVersion = stringField(body, ["appVersion", "app_version"], 40, "invalid_app_version");
if (!appVersion.ok) return appVersion;
const platform = stringField(body, ["platform"], 40, "invalid_platform");
if (!platform.ok) return platform;
const occurredAt = timestampField(
body,
["timestampMillis", "timestamp_millis", "occurredAt", "occurred_at"],
);
if (!occurredAt.ok) return occurredAt;
const whatHappened = stringField(
body,
["whatHappened", "what_happened"],
4_000,
"missing_fields",
true,
true,
);
if (!whatHappened.ok) return whatHappened;
const expected = stringField(body, ["expected"], 4_000, "missing_fields", true, true);
if (!expected.ok) return expected;
const steps = stringField(body, ["steps"], 4_000, "invalid_steps");
if (!steps.ok) return steps;
const contact = stringField(body, ["contact"], 320, "invalid_contact");
if (!contact.ok) return contact;
const includeLogs = booleanField(
body,
["includeLogs", "include_logs"],
"invalid_include_logs",
false,
);
if (!includeLogs.ok) return includeLogs;
const logs = stringField(body, ["logs"], MAX_LOG_BYTES, "invalid_logs");
if (!logs.ok) return logs;
const device = normalizeDevice(pick(body, ["device"]));
if (!device.ok) return device;
const breadcrumbs = normalizeBreadcrumbs(pick(body, ["breadcrumbs"]));
if (!breadcrumbs.ok) return breadcrumbs;
const version = schemaVersion(body);
if (!version.ok) return version;
return success({
id: id.value,
installId: installId.value,
appVersion: appVersion.value,
platform: platform.value,
occurredAt: occurredAt.value,
whatHappened: whatHappened.value,
expected: expected.value,
steps: steps.value,
contact: contact.value,
logs: includeLogs.value === true ? logs.value : "",
device: device.value,
breadcrumbs: breadcrumbs.value,
schemaVersion: version.value,
});
}
function normalizeEvent(raw: unknown): InputResult<NormalizedEvent> {
if (!isPlainObject(raw)) return failure(400, "invalid_event");
const name = stringField(raw, ["name"], 64, "invalid_event", true, true);
if (!name.ok) return name;
const timestamp = timestampField(raw, ["timestampMillis", "timestamp_millis", "ts"]);
if (!timestamp.ok) return failure(400, "invalid_event");
const properties = normalizeProperties(pick(raw, ["properties", "props"]), "invalid_event");
if (!properties.ok) return properties;
const version = schemaVersion(raw);
if (!version.ok) return version;
return success({
name: name.value,
timestampMillis: timestamp.value,
properties: properties.value,
schemaVersion: version.value,
});
}
function normalizeBreadcrumbs(raw: unknown | typeof MISSING): InputResult<NormalizedBreadcrumb[]> {
if (raw === MISSING) return success([]);
if (!Array.isArray(raw)) return failure(400, "invalid_breadcrumbs");
const breadcrumbs: NormalizedBreadcrumb[] = [];
for (const item of raw.slice(0, MAX_BREADCRUMBS)) {
if (!isPlainObject(item)) return failure(400, "invalid_breadcrumbs");
const name = stringField(item, ["name"], 64, "invalid_breadcrumbs", true, true);
if (!name.ok) return name;
const timestamp = timestampField(item, ["timestampMillis", "timestamp_millis", "ts"]);
if (!timestamp.ok) return failure(400, "invalid_breadcrumbs");
const properties = normalizeProperties(
pick(item, ["properties", "props"]),
"invalid_breadcrumbs",
);
if (!properties.ok) return properties;
breadcrumbs.push({
name: name.value,
timestampMillis: timestamp.value,
properties: properties.value,
});
if (jsonBytes(breadcrumbs) > MAX_BREADCRUMBS_JSON_BYTES) {
breadcrumbs.pop();
break;
}
}
return success(breadcrumbs);
}
function normalizeProperties(
raw: unknown | typeof MISSING,
error: string,
): InputResult<NormalizedProperties> {
if (raw === MISSING) return success({});
if (!isPlainObject(raw)) return failure(400, error);
const entries: Array<[string, string]> = [];
const normalizedKeys = new Set<string>();
for (const [key, value] of Object.entries(raw).slice(0, MAX_PROPERTIES)) {
if (typeof value !== "string") return failure(400, error);
const normalizedKey = truncateUtf8(key, 40);
if (normalizedKey.length === 0 || normalizedKeys.has(normalizedKey)) {
return failure(400, error);
}
normalizedKeys.add(normalizedKey);
entries.push([normalizedKey, truncateUtf8(value, 128)]);
}
return success(Object.fromEntries(entries));
}
function normalizeDevice(raw: unknown | typeof MISSING): InputResult<NormalizedDevice> {
if (raw === MISSING) raw = {};
if (!isPlainObject(raw)) return failure(400, "invalid_device");
const deviceName = stringField(raw, ["deviceName", "device_name"], 128, "invalid_device");
if (!deviceName.ok) return deviceName;
const deviceModel = stringField(raw, ["deviceModel", "device_model"], 128, "invalid_device");
if (!deviceModel.ok) return deviceModel;
const operatingSystem = stringField(
raw,
["operatingSystem", "operating_system"],
192,
"invalid_device",
);
if (!operatingSystem.ok) return operatingSystem;
const network = stringField(raw, ["network"], 96, "invalid_device");
if (!network.ok) return network;
const batteryLevel = stringField(raw, ["batteryLevel", "battery_level"], 64, "invalid_device");
if (!batteryLevel.ok) return batteryLevel;
const device: NormalizedDevice = {
deviceName: deviceName.value,
deviceModel: deviceModel.value,
operatingSystem: operatingSystem.value,
network: network.value,
batteryLevel: batteryLevel.value,
};
if (jsonBytes(device) > MAX_DEVICE_JSON_BYTES) {
return failure(400, "invalid_device");
}
return success(device);
}
function stringField(
object: JsonObject,
keys: readonly string[],
maxBytes: number,
error: string,
required = false,
nonEmpty = false,
): InputResult<string> {
const raw = pick(object, keys);
if (raw === MISSING) {
return required ? failure(400, error) : success("");
}
if (typeof raw !== "string") return failure(400, error);
const value = truncateUtf8(raw, maxBytes);
if (nonEmpty && value.trim().length === 0) return failure(400, error);
return success(value);
}
function idField(
object: JsonObject,
keys: readonly string[],
error: string,
): InputResult<string> {
const raw = pick(object, keys);
if (typeof raw !== "string" || !UUID_PATTERN.test(raw)) return failure(400, error);
return success(raw.toLowerCase());
}
function installIdField(object: JsonObject): InputResult<string> {
const raw = pick(object, ["installId", "install_id"]);
if (raw === MISSING || raw === "") return success("unknown");
if (
typeof raw !== "string" ||
raw.trim().length === 0 ||
/[\u0000-\u001f\u007f]/.test(raw)
) {
return failure(400, "invalid_install_id");
}
return success(truncateUtf8(raw, 80));
}
function timestampField(object: JsonObject, keys: readonly string[]): InputResult<number> {
const raw = pick(object, keys);
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0) {
return failure(400, "invalid_timestamp");
}
return success(raw);
}
function booleanField(
object: JsonObject,
keys: readonly string[],
error: string,
required: true,
): InputResult<boolean>;
function booleanField(
object: JsonObject,
keys: readonly string[],
error: string,
required: false,
): InputResult<boolean | undefined>;
function booleanField(
object: JsonObject,
keys: readonly string[],
error: string,
required: boolean,
): InputResult<boolean | undefined> {
const raw = pick(object, keys);
if (raw === MISSING) {
return required ? failure(400, error) : success(undefined);
}
return typeof raw === "boolean" ? success(raw) : failure(400, error);
}
function schemaVersion(object: JsonObject): InputResult<1> {
const raw = pick(object, ["schemaVersion", "schema_version"]);
if (raw === MISSING || raw === 1) return success(1);
return failure(400, "unsupported_schema_version");
}
function pick(object: JsonObject, keys: readonly string[]): unknown | typeof MISSING {
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(object, key)) return object[key];
}
return MISSING;
}
function isPlainObject(value: unknown): value is JsonObject {
if (value == null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function isApplicationJson(contentType: string | null): boolean {
if (contentType == null) return false;
const [mediaType, ...parameters] = contentType.split(";");
if (mediaType?.trim().toLowerCase() !== "application/json") return false;
for (const parameter of parameters) {
const separator = parameter.indexOf("=");
if (separator < 0) continue;
if (parameter.slice(0, separator).trim().toLowerCase() !== "charset") continue;
const charset = parameter
.slice(separator + 1)
.trim()
.replace(/^"(.*)"$/, "$1")
.toLowerCase();
if (charset !== "utf-8" && charset !== "utf8") return false;
}
return true;
}
function joinChunks(chunks: readonly Uint8Array[], totalBytes: number): Uint8Array {
if (chunks.length === 1) return chunks[0] ?? new Uint8Array();
const bytes = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
function truncateUtf8(value: string, maxBytes: number): string {
const bytes = UTF8_ENCODER.encode(value);
if (bytes.byteLength <= maxBytes) return value;
for (let end = maxBytes; end >= Math.max(0, maxBytes - 3); end -= 1) {
try {
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(0, end),
);
} catch {
// A UTF-8 boundary is at most three bytes behind the byte cap.
}
}
return "";
}
function jsonBytes(value: unknown): number {
return UTF8_ENCODER.encode(JSON.stringify(value)).byteLength;
}
function success<T>(value: T): InputResult<T> {
return { ok: true, value };
}
function failure(status: 400 | 413 | 415, error: string): InputFailure {
return { ok: false, status, error };
}