diff --git a/src/xdr/XdrEncoder.ts b/src/xdr/XdrEncoder.ts new file mode 100644 index 00000000..fba2681d --- /dev/null +++ b/src/xdr/XdrEncoder.ts @@ -0,0 +1,272 @@ +import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib'; +import type {BinaryJsonEncoder} from '../types'; + +/** + * XDR (External Data Representation) binary encoder for basic value encoding. + * Implements XDR binary encoding according to RFC 4506. + * + * Key XDR encoding principles: + * - All data types are aligned to 4-byte boundaries + * - Multi-byte quantities are transmitted in big-endian byte order + * - Strings and opaque data are padded to 4-byte boundaries + * - Variable-length arrays and strings are preceded by their length + */ +export class XdrEncoder implements BinaryJsonEncoder { + constructor(public readonly writer: IWriter & IWriterGrowable) {} + + public encode(value: unknown): Uint8Array { + const writer = this.writer; + writer.reset(); + this.writeAny(value); + return writer.flush(); + } + + /** + * Called when the encoder encounters a value that it does not know how to encode. + */ + public writeUnknown(value: unknown): void { + this.writeVoid(); + } + + public writeAny(value: unknown): void { + switch (typeof value) { + case 'boolean': + return this.writeBoolean(value); + case 'number': + return this.writeNumber(value); + case 'string': + return this.writeStr(value); + case 'object': { + if (value === null) return this.writeVoid(); + const constructor = value.constructor; + switch (constructor) { + case Uint8Array: + return this.writeBin(value as Uint8Array); + default: + return this.writeUnknown(value); + } + } + case 'bigint': + return this.writeHyper(value); + case 'undefined': + return this.writeVoid(); + default: + return this.writeUnknown(value); + } + } + + /** + * Writes an XDR void value (no data is actually written). + */ + public writeVoid(): void { + // Void values are encoded as no data + } + + /** + * Writes an XDR null value (for interface compatibility). + */ + public writeNull(): void { + this.writeVoid(); + } + + /** + * Writes an XDR boolean value as a 4-byte integer. + */ + public writeBoolean(bool: boolean): void { + this.writeInt(bool ? 1 : 0); + } + + /** + * Writes an XDR signed 32-bit integer in big-endian format. + */ + public writeInt(int: number): void { + const writer = this.writer; + writer.ensureCapacity(4); + writer.view.setInt32(writer.x, Math.trunc(int), false); // big-endian + writer.move(4); + } + + /** + * Writes an XDR unsigned 32-bit integer in big-endian format. + */ + public writeUnsignedInt(uint: number): void { + const writer = this.writer; + writer.ensureCapacity(4); + writer.view.setUint32(writer.x, Math.trunc(uint) >>> 0, false); // big-endian + writer.move(4); + } + + /** + * Writes an XDR signed 64-bit integer (hyper) in big-endian format. + */ + public writeHyper(hyper: number | bigint): void { + const writer = this.writer; + writer.ensureCapacity(8); + + if (typeof hyper === 'bigint') { + writer.view.setBigInt64(writer.x, hyper, false); // big-endian + } else { + const truncated = Math.trunc(hyper); + const high = Math.floor(truncated / 0x100000000); + const low = truncated >>> 0; + writer.view.setInt32(writer.x, high, false); // high 32 bits + writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + } + writer.move(8); + } + + /** + * Writes an XDR unsigned 64-bit integer (unsigned hyper) in big-endian format. + */ + public writeUnsignedHyper(uhyper: number | bigint): void { + const writer = this.writer; + writer.ensureCapacity(8); + + if (typeof uhyper === 'bigint') { + writer.view.setBigUint64(writer.x, uhyper, false); // big-endian + } else { + const truncated = Math.trunc(Math.abs(uhyper)); + const high = Math.floor(truncated / 0x100000000); + const low = truncated >>> 0; + writer.view.setUint32(writer.x, high, false); // high 32 bits + writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + } + writer.move(8); + } + + /** + * Writes an XDR float value using IEEE 754 single-precision in big-endian format. + */ + public writeFloat(float: number): void { + const writer = this.writer; + writer.ensureCapacity(4); + writer.view.setFloat32(writer.x, float, false); // big-endian + writer.move(4); + } + + /** + * Writes an XDR double value using IEEE 754 double-precision in big-endian format. + */ + public writeDouble(double: number): void { + const writer = this.writer; + writer.ensureCapacity(8); + writer.view.setFloat64(writer.x, double, false); // big-endian + writer.move(8); + } + + /** + * Writes an XDR quadruple value (128-bit float). + * Note: JavaScript doesn't have native 128-bit float support. + */ + public writeQuadruple(quad: number): void { + throw new Error('not implemented'); + } + + /** + * Writes XDR opaque data with fixed length. + * Data is padded to 4-byte boundary. + */ + public writeOpaque(data: Uint8Array): void { + const size = data.length; + const writer = this.writer; + const paddedSize = Math.ceil(size / 4) * 4; + writer.ensureCapacity(paddedSize); + + // Write data + writer.buf(data, size); + + // Write padding bytes + const padding = paddedSize - size; + for (let i = 0; i < padding; i++) { + writer.u8(0); + } + } + + /** + * Writes XDR variable-length opaque data. + * Length is written first, followed by data padded to 4-byte boundary. + */ + public writeVarlenOpaque(data: Uint8Array): void { + this.writeUnsignedInt(data.length); + this.writeOpaque(data); + } + + /** + * Writes an XDR string with UTF-8 encoding. + * Length is written first, followed by UTF-8 bytes padded to 4-byte boundary. + */ + public writeStr(str: string): void { + const writer = this.writer; + + // Write string using writer's UTF-8 method and get actual byte count + const lengthOffset = writer.x; + writer.x += 4; // Reserve space for length + const bytesWritten = writer.utf8(str); + + // Calculate and write padding + const paddedSize = Math.ceil(bytesWritten / 4) * 4; + const padding = paddedSize - bytesWritten; + for (let i = 0; i < padding; i++) { + writer.u8(0); + } + + // Go back and write the actual byte length + const currentPos = writer.x; + writer.x = lengthOffset; + this.writeUnsignedInt(bytesWritten); + writer.x = currentPos; + } + + public writeArr(arr: unknown[]): void { + throw new Error('not implemented'); + } + + public writeObj(obj: Record): void { + throw new Error('not implemented'); + } + + // BinaryJsonEncoder interface methods + + /** + * Generic number writing - determines type based on value + */ + public writeNumber(num: number): void { + if (Number.isInteger(num)) { + if (num >= -2147483648 && num <= 2147483647) { + this.writeInt(num); + } else { + this.writeHyper(num); + } + } else { + this.writeDouble(num); + } + } + + /** + * Writes an integer value + */ + public writeInteger(int: number): void { + this.writeInt(int); + } + + /** + * Writes an unsigned integer value + */ + public writeUInteger(uint: number): void { + this.writeUnsignedInt(uint); + } + + /** + * Writes binary data + */ + public writeBin(buf: Uint8Array): void { + this.writeVarlenOpaque(buf); + } + + /** + * Writes an ASCII string (same as regular string in XDR) + */ + public writeAsciiStr(str: string): void { + this.writeStr(str); + } +} diff --git a/src/xdr/XdrSchemaEncoder.ts b/src/xdr/XdrSchemaEncoder.ts new file mode 100644 index 00000000..ca9c21fa --- /dev/null +++ b/src/xdr/XdrSchemaEncoder.ts @@ -0,0 +1,299 @@ +import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib'; +import {XdrEncoder} from './XdrEncoder'; +import {XdrUnion} from './XdrUnion'; +import type { + XdrSchema, + XdrEnumSchema, + XdrOpaqueSchema, + XdrVarlenOpaqueSchema, + XdrStringSchema, + XdrArraySchema, + XdrVarlenArraySchema, + XdrStructSchema, + XdrUnionSchema, +} from './types'; + +export class XdrSchemaEncoder { + private encoder: XdrEncoder; + + constructor(public readonly writer: IWriter & IWriterGrowable) { + this.encoder = new XdrEncoder(writer); + } + + public encode(value: unknown, schema: XdrSchema): Uint8Array { + this.writer.reset(); + this.writeValue(value, schema); + return this.writer.flush(); + } + + public writeVoid(schema: XdrSchema): void { + this.validateSchemaType(schema, 'void'); + this.encoder.writeVoid(); + } + + public writeInt(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'int'); + if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) { + throw new Error('Value is not a valid 32-bit signed integer'); + } + this.encoder.writeInt(value); + } + + public writeUnsignedInt(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'unsigned_int'); + if (!Number.isInteger(value) || value < 0 || value > 4294967295) { + throw new Error('Value is not a valid 32-bit unsigned integer'); + } + this.encoder.writeUnsignedInt(value); + } + + public writeBoolean(value: boolean, schema: XdrSchema): void { + this.validateSchemaType(schema, 'boolean'); + this.encoder.writeBoolean(value); + } + + public writeHyper(value: number | bigint, schema: XdrSchema): void { + this.validateSchemaType(schema, 'hyper'); + this.encoder.writeHyper(value); + } + + public writeUnsignedHyper(value: number | bigint, schema: XdrSchema): void { + this.validateSchemaType(schema, 'unsigned_hyper'); + if ((typeof value === 'number' && value < 0) || (typeof value === 'bigint' && value < BigInt(0))) { + throw new Error('Value is not a valid unsigned integer'); + } + this.encoder.writeUnsignedHyper(value); + } + + public writeFloat(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'float'); + this.encoder.writeFloat(value); + } + + public writeDouble(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'double'); + this.encoder.writeDouble(value); + } + + public writeQuadruple(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'quadruple'); + this.encoder.writeQuadruple(value); + } + + public writeEnum(value: string, schema: XdrEnumSchema): void { + if (schema.type !== 'enum') { + throw new Error('Schema is not an enum schema'); + } + + if (!(value in schema.values)) { + throw new Error(`Invalid enum value: ${value}`); + } + + this.encoder.writeInt(schema.values[value]); + } + + public writeOpaque(value: Uint8Array, schema: XdrOpaqueSchema): void { + if (schema.type !== 'opaque') { + throw new Error('Schema is not an opaque schema'); + } + + if (value.length !== schema.size) { + throw new Error(`Opaque data length ${value.length} does not match schema size ${schema.size}`); + } + + this.encoder.writeOpaque(value); + } + + public writeVarlenOpaque(value: Uint8Array, schema: XdrVarlenOpaqueSchema): void { + if (schema.type !== 'vopaque') { + throw new Error('Schema is not a variable-length opaque schema'); + } + + if (schema.size !== undefined && value.length > schema.size) { + throw new Error(`Opaque data length ${value.length} exceeds maximum size ${schema.size}`); + } + + this.encoder.writeVarlenOpaque(value); + } + + public writeString(value: string, schema: XdrStringSchema): void { + if (schema.type !== 'string') { + throw new Error('Schema is not a string schema'); + } + + if (schema.size !== undefined && value.length > schema.size) { + throw new Error(`String length ${value.length} exceeds maximum size ${schema.size}`); + } + + this.encoder.writeStr(value); + } + + public writeArray(value: unknown[], schema: XdrArraySchema): void { + if (schema.type !== 'array') { + throw new Error('Schema is not an array schema'); + } + + if (value.length !== schema.size) { + throw new Error(`Array length ${value.length} does not match schema size ${schema.size}`); + } + + for (const item of value) { + this.writeValue(item, schema.elements); + } + } + + public writeVarlenArray(value: unknown[], schema: XdrVarlenArraySchema): void { + if (schema.type !== 'varray') { + throw new Error('Schema is not a variable-length array schema'); + } + + if (schema.size !== undefined && value.length > schema.size) { + throw new Error(`Array length ${value.length} exceeds maximum size ${schema.size}`); + } + + this.encoder.writeUnsignedInt(value.length); + for (const item of value) { + this.writeValue(item, schema.elements); + } + } + + public writeStruct(value: Record, schema: XdrStructSchema): void { + if (schema.type !== 'struct') { + throw new Error('Schema is not a struct schema'); + } + + for (const [fieldSchema, fieldName] of schema.fields) { + if (!(fieldName in value)) { + throw new Error(`Missing required field: ${fieldName}`); + } + this.writeValue(value[fieldName], fieldSchema); + } + } + + public writeUnion(value: unknown, schema: XdrUnionSchema, discriminant: number | string | boolean): void { + if (schema.type !== 'union') { + throw new Error('Schema is not a union schema'); + } + + const arm = schema.arms.find(([armDiscriminant]) => armDiscriminant === discriminant); + if (!arm) { + if (schema.default) { + this.writeDiscriminant(discriminant); + this.writeValue(value, schema.default); + } else { + throw new Error(`No matching arm found for discriminant: ${discriminant}`); + } + } else { + this.writeDiscriminant(discriminant); + this.writeValue(value, arm[1]); + } + } + + public writeNumber(value: number, schema: XdrSchema): void { + switch (schema.type) { + case 'int': + this.writeInt(value, schema); + break; + case 'unsigned_int': + this.writeUnsignedInt(value, schema); + break; + case 'hyper': + this.writeHyper(value, schema); + break; + case 'unsigned_hyper': + this.writeUnsignedHyper(value, schema); + break; + case 'float': + this.writeFloat(value, schema); + break; + case 'double': + this.writeDouble(value, schema); + break; + case 'quadruple': + this.writeQuadruple(value, schema); + break; + default: + throw new Error(`Schema type ${schema.type} is not a numeric type`); + } + } + + private writeValue(value: unknown, schema: XdrSchema): void { + switch (schema.type) { + case 'void': + this.encoder.writeVoid(); + break; + case 'int': + this.encoder.writeInt(value as number); + break; + case 'unsigned_int': + this.encoder.writeUnsignedInt(value as number); + break; + case 'boolean': + this.encoder.writeBoolean(value as boolean); + break; + case 'hyper': + this.encoder.writeHyper(value as number | bigint); + break; + case 'unsigned_hyper': + this.encoder.writeUnsignedHyper(value as number | bigint); + break; + case 'float': + this.encoder.writeFloat(value as number); + break; + case 'double': + this.encoder.writeDouble(value as number); + break; + case 'quadruple': + this.encoder.writeQuadruple(value as number); + break; + case 'enum': + this.writeEnum(value as string, schema as XdrEnumSchema); + break; + case 'opaque': + this.writeOpaque(value as Uint8Array, schema as XdrOpaqueSchema); + break; + case 'vopaque': + this.writeVarlenOpaque(value as Uint8Array, schema as XdrVarlenOpaqueSchema); + break; + case 'string': + this.writeString(value as string, schema as XdrStringSchema); + break; + case 'array': + this.writeArray(value as unknown[], schema as XdrArraySchema); + break; + case 'varray': + this.writeVarlenArray(value as unknown[], schema as XdrVarlenArraySchema); + break; + case 'struct': + this.writeStruct(value as Record, schema as XdrStructSchema); + break; + case 'union': + if (value instanceof XdrUnion) { + this.writeUnion(value.value, schema as XdrUnionSchema, value.discriminant); + } else { + throw new Error('Union values must be wrapped in XdrUnion class'); + } + break; + default: + throw new Error(`Unknown schema type: ${(schema as any).type}`); + } + } + + private validateSchemaType(schema: XdrSchema, expectedType: string): void { + if (schema.type !== expectedType) { + throw new Error(`Expected schema type ${expectedType}, got ${schema.type}`); + } + } + + private writeDiscriminant(discriminant: number | string | boolean): void { + if (typeof discriminant === 'number') { + this.encoder.writeInt(discriminant); + } else if (typeof discriminant === 'boolean') { + this.encoder.writeBoolean(discriminant); + } else { + // For string discriminants, we need to know the enum mapping + // This is a simplified implementation + throw new Error('String discriminants require enum schema context'); + } + } +} diff --git a/src/xdr/XdrSchemaValidator.ts b/src/xdr/XdrSchemaValidator.ts new file mode 100644 index 00000000..3ce652dc --- /dev/null +++ b/src/xdr/XdrSchemaValidator.ts @@ -0,0 +1,289 @@ +import type { + XdrSchema, + XdrPrimitiveSchema, + XdrWidePrimitiveSchema, + XdrCompositeSchema, + XdrEnumSchema, + XdrOpaqueSchema, + XdrVarlenOpaqueSchema, + XdrStringSchema, + XdrArraySchema, + XdrVarlenArraySchema, + XdrStructSchema, + XdrUnionSchema, +} from './types'; + +/** + * XDR schema validator for validating XDR schemas and values according to RFC 4506. + */ +export class XdrSchemaValidator { + /** + * Validates an XDR schema structure. + */ + public validateSchema(schema: XdrSchema): boolean { + try { + return this.validateSchemaInternal(schema); + } catch { + return false; + } + } + + /** + * Validates if a value conforms to the given XDR schema. + */ + public validateValue(value: unknown, schema: XdrSchema): boolean { + try { + return this.validateValueInternal(value, schema); + } catch { + return false; + } + } + + private validateSchemaInternal(schema: XdrSchema): boolean { + if (!schema || typeof schema !== 'object' || !schema.type) { + return false; + } + + switch (schema.type) { + // Primitive types + case 'void': + case 'int': + case 'unsigned_int': + case 'boolean': + case 'hyper': + case 'unsigned_hyper': + case 'float': + case 'double': + case 'quadruple': + return true; + + case 'enum': + return this.validateEnumSchema(schema as XdrEnumSchema); + + // Wide primitive types + case 'opaque': + return this.validateOpaqueSchema(schema as XdrOpaqueSchema); + + case 'vopaque': + return this.validateVarlenOpaqueSchema(schema as XdrVarlenOpaqueSchema); + + case 'string': + return this.validateStringSchema(schema as XdrStringSchema); + + // Composite types + case 'array': + return this.validateArraySchema(schema as XdrArraySchema); + + case 'varray': + return this.validateVarlenArraySchema(schema as XdrVarlenArraySchema); + + case 'struct': + return this.validateStructSchema(schema as XdrStructSchema); + + case 'union': + return this.validateUnionSchema(schema as XdrUnionSchema); + + default: + return false; + } + } + + private validateEnumSchema(schema: XdrEnumSchema): boolean { + if (!schema.values || typeof schema.values !== 'object') { + return false; + } + + const values = Object.values(schema.values); + const uniqueValues = new Set(values); + + // Check for duplicate values + if (values.length !== uniqueValues.size) { + return false; + } + + // Check that all values are integers + return values.every((value) => Number.isInteger(value)); + } + + private validateOpaqueSchema(schema: XdrOpaqueSchema): boolean { + return typeof schema.size === 'number' && Number.isInteger(schema.size) && schema.size >= 0; + } + + private validateVarlenOpaqueSchema(schema: XdrVarlenOpaqueSchema): boolean { + return !schema.size || (typeof schema.size === 'number' && Number.isInteger(schema.size) && schema.size >= 0); + } + + private validateStringSchema(schema: XdrStringSchema): boolean { + return !schema.size || (typeof schema.size === 'number' && Number.isInteger(schema.size) && schema.size >= 0); + } + + private validateArraySchema(schema: XdrArraySchema): boolean { + if (!schema.elements || typeof schema.size !== 'number' || !Number.isInteger(schema.size) || schema.size < 0) { + return false; + } + return this.validateSchemaInternal(schema.elements); + } + + private validateVarlenArraySchema(schema: XdrVarlenArraySchema): boolean { + if (!schema.elements) { + return false; + } + if (schema.size !== undefined) { + if (typeof schema.size !== 'number' || !Number.isInteger(schema.size) || schema.size < 0) { + return false; + } + } + return this.validateSchemaInternal(schema.elements); + } + + private validateStructSchema(schema: XdrStructSchema): boolean { + if (!Array.isArray(schema.fields)) { + return false; + } + + const fieldNames = new Set(); + for (const field of schema.fields) { + if (!Array.isArray(field) || field.length !== 2) { + return false; + } + + const [fieldSchema, fieldName] = field; + + if (typeof fieldName !== 'string' || fieldName === '') { + return false; + } + + if (fieldNames.has(fieldName)) { + return false; // Duplicate field name + } + fieldNames.add(fieldName); + + if (!this.validateSchemaInternal(fieldSchema)) { + return false; + } + } + + return true; + } + + private validateUnionSchema(schema: XdrUnionSchema): boolean { + if (!Array.isArray(schema.arms) || schema.arms.length === 0) { + return false; + } + + const discriminants = new Set(); + for (const arm of schema.arms) { + if (!Array.isArray(arm) || arm.length !== 2) { + return false; + } + + const [discriminant, armSchema] = arm; + + // Check for duplicate discriminants + if (discriminants.has(discriminant)) { + return false; + } + discriminants.add(discriminant); + + // Validate discriminant type + if (typeof discriminant !== 'number' && typeof discriminant !== 'string' && typeof discriminant !== 'boolean') { + return false; + } + + if (!this.validateSchemaInternal(armSchema)) { + return false; + } + } + + // Validate default schema if present + if (schema.default && !this.validateSchemaInternal(schema.default)) { + return false; + } + + return true; + } + + private validateValueInternal(value: unknown, schema: XdrSchema): boolean { + switch (schema.type) { + case 'void': + return value === null || value === undefined; + + case 'int': + return typeof value === 'number' && Number.isInteger(value) && value >= -2147483648 && value <= 2147483647; + + case 'unsigned_int': + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 4294967295; + + case 'boolean': + return typeof value === 'boolean'; + + case 'hyper': + return (typeof value === 'number' && Number.isInteger(value)) || typeof value === 'bigint'; + + case 'unsigned_hyper': + return ( + (typeof value === 'number' && Number.isInteger(value) && value >= 0) || + (typeof value === 'bigint' && value >= BigInt(0)) + ); + + case 'float': + case 'double': + case 'quadruple': + return typeof value === 'number'; + + case 'enum': + const enumSchema = schema as XdrEnumSchema; + return typeof value === 'string' && value in enumSchema.values; + + case 'opaque': + const opaqueSchema = schema as XdrOpaqueSchema; + return value instanceof Uint8Array && value.length === opaqueSchema.size; + + case 'vopaque': + const vopaqueSchema = schema as XdrVarlenOpaqueSchema; + return value instanceof Uint8Array && (!vopaqueSchema.size || value.length <= vopaqueSchema.size); + + case 'string': + const stringSchema = schema as XdrStringSchema; + return typeof value === 'string' && (!stringSchema.size || value.length <= stringSchema.size); + + case 'array': + const arraySchema = schema as XdrArraySchema; + return ( + Array.isArray(value) && + value.length === arraySchema.size && + value.every((item) => this.validateValueInternal(item, arraySchema.elements)) + ); + + case 'varray': + const varraySchema = schema as XdrVarlenArraySchema; + return ( + Array.isArray(value) && + (!varraySchema.size || value.length <= varraySchema.size) && + value.every((item) => this.validateValueInternal(item, varraySchema.elements)) + ); + + case 'struct': + const structSchema = schema as XdrStructSchema; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const valueObj = value as Record; + return structSchema.fields.every( + ([fieldSchema, fieldName]) => + fieldName in valueObj && this.validateValueInternal(valueObj[fieldName], fieldSchema), + ); + + case 'union': + const unionSchema = schema as XdrUnionSchema; + // For union validation, we need additional context about which arm is selected + // This is a simplified validation - in practice, the discriminant would be known + const matchesArm = unionSchema.arms.some(([, armSchema]) => this.validateValueInternal(value, armSchema)); + const matchesDefault = unionSchema.default ? this.validateValueInternal(value, unionSchema.default) : false; + return matchesArm || matchesDefault; + + default: + return false; + } + } +} diff --git a/src/xdr/XdrUnion.ts b/src/xdr/XdrUnion.ts new file mode 100644 index 00000000..053edd04 --- /dev/null +++ b/src/xdr/XdrUnion.ts @@ -0,0 +1,11 @@ +/** + * XDR Union data type that contains a discriminant and value. + * Used for encoding XDR union types where the discriminant determines + * which arm of the union is active. + */ +export class XdrUnion { + constructor( + public readonly discriminant: number | string | boolean, + public readonly value: T, + ) {} +} diff --git a/src/xdr/__tests__/XdrEncoder.spec.ts b/src/xdr/__tests__/XdrEncoder.spec.ts new file mode 100644 index 00000000..4f4dc23c --- /dev/null +++ b/src/xdr/__tests__/XdrEncoder.spec.ts @@ -0,0 +1,423 @@ +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {XdrEncoder} from '../XdrEncoder'; + +describe('XdrEncoder', () => { + let writer: Writer; + let encoder: XdrEncoder; + + beforeEach(() => { + writer = new Writer(); + encoder = new XdrEncoder(writer); + }); + + describe('primitive types', () => { + test('encodes void', () => { + encoder.writeVoid(); + const result = writer.flush(); + expect(result.length).toBe(0); + }); + + test('encodes boolean true', () => { + encoder.writeBoolean(true); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 1])); // big-endian 32-bit 1 + }); + + test('encodes boolean false', () => { + encoder.writeBoolean(false); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // big-endian 32-bit 0 + }); + + test('encodes positive int', () => { + encoder.writeInt(42); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); // big-endian 32-bit 42 + }); + + test('encodes negative int', () => { + encoder.writeInt(-1); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([255, 255, 255, 255])); // big-endian 32-bit -1 + }); + + test('encodes large positive int', () => { + encoder.writeInt(0x12345678); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78])); + }); + + test('encodes unsigned int', () => { + encoder.writeUnsignedInt(0xffffffff); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([255, 255, 255, 255])); // big-endian 32-bit max uint + }); + + test('encodes hyper from number', () => { + encoder.writeHyper(0x123456789abcdef0); + const result = writer.flush(); + // JavaScript loses precision for large numbers, but we test what we can + expect(result.length).toBe(8); + }); + + test('encodes hyper from bigint', () => { + encoder.writeHyper(BigInt('0x123456789ABCDEF0')); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); + }); + + test('encodes negative hyper from bigint', () => { + encoder.writeHyper(BigInt(-1)); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255])); + }); + + test('encodes unsigned hyper from bigint', () => { + encoder.writeUnsignedHyper(BigInt('0x123456789ABCDEF0')); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); + }); + + test('encodes float', () => { + encoder.writeFloat(3.14159); + const result = writer.flush(); + expect(result.length).toBe(4); + // Verify it's a valid IEEE 754 float in big-endian + const view = new DataView(result.buffer); + expect(view.getFloat32(0, false)).toBeCloseTo(3.14159, 5); + }); + + test('encodes double', () => { + encoder.writeDouble(3.141592653589793); + const result = writer.flush(); + expect(result.length).toBe(8); + // Verify it's a valid IEEE 754 double in big-endian + const view = new DataView(result.buffer); + expect(view.getFloat64(0, false)).toBeCloseTo(3.141592653589793, 15); + }); + + test('encodes quadruple', () => { + expect(() => encoder.writeQuadruple(3.14159)).toThrow('not implemented'); + }); + }); + + describe('opaque data', () => { + test('encodes fixed opaque data', () => { + const data = new Uint8Array([1, 2, 3]); + encoder.writeOpaque(data); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([1, 2, 3, 0])); // padded to 4 bytes + }); + + test('encodes fixed opaque data with exact 4-byte boundary', () => { + const data = new Uint8Array([1, 2, 3, 4]); + encoder.writeOpaque(data); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); // no padding needed + }); + + test('encodes variable-length opaque data', () => { + const data = new Uint8Array([1, 2, 3]); + encoder.writeVarlenOpaque(data); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 3, // length + 1, + 2, + 3, + 0, // data + padding + ]), + ); + }); + + test('encodes empty variable-length opaque data', () => { + const data = new Uint8Array([]); + encoder.writeVarlenOpaque(data); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length + }); + }); + + describe('strings', () => { + test('encodes simple string', () => { + encoder.writeStr('hello'); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 5, // length + 104, + 101, + 108, + 108, + 111, + 0, + 0, + 0, // 'hello' + padding + ]), + ); + }); + + test('encodes empty string', () => { + encoder.writeStr(''); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length + }); + + test('encodes UTF-8 string', () => { + encoder.writeStr('café'); + const result = writer.flush(); + // 'café' in UTF-8 is [99, 97, 102, 195, 169] (5 bytes) + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 5, // length + 99, + 97, + 102, + 195, + 169, + 0, + 0, + 0, // UTF-8 bytes + padding + ]), + ); + }); + + test('encodes string that fits exactly in 4-byte boundary', () => { + encoder.writeStr('test'); // 4 bytes + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 4, // length + 116, + 101, + 115, + 116, // 'test' (no padding needed) + ]), + ); + }); + }); + + describe('encode method', () => { + test('encodes various types through encode method', () => { + const result = encoder.encode(42); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('handles null', () => { + const result = encoder.encode(null); + expect(result.length).toBe(0); // void + }); + + test('handles undefined', () => { + const result = encoder.encode(undefined); + expect(result.length).toBe(0); // void + }); + + test('handles boolean', () => { + const result = encoder.encode(true); + expect(result).toEqual(new Uint8Array([0, 0, 0, 1])); + }); + + test('handles string', () => { + const result = encoder.encode('hi'); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 2, // length + 104, + 105, + 0, + 0, // 'hi' + padding + ]), + ); + }); + + test('handles bigint', () => { + const result = encoder.encode(BigInt(123)); + expect(result.length).toBe(8); // hyper + }); + + test('handles Uint8Array', () => { + const result = encoder.encode(new Uint8Array([1, 2])); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 2, // length + 1, + 2, + 0, + 0, // data + padding + ]), + ); + }); + + test('handles unknown types', () => { + const result = encoder.encode(Symbol('test')); + expect(result.length).toBe(0); // void for unknown + }); + }); + + describe('BinaryJsonEncoder interface methods', () => { + test('writeNumber chooses appropriate type', () => { + // Integer within 32-bit range + encoder.writeNumber(42); + let result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + + writer.reset(); + + // Large integer (uses hyper) + encoder.writeNumber(0x100000000); + result = writer.flush(); + expect(result.length).toBe(8); + + writer.reset(); + + // Float + encoder.writeNumber(3.14); + result = writer.flush(); + expect(result.length).toBe(8); // double + }); + + test('writeInteger', () => { + encoder.writeInteger(42); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('writeUInteger', () => { + encoder.writeUInteger(42); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('writeBin', () => { + encoder.writeBin(new Uint8Array([1, 2, 3])); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 3, // length + 1, + 2, + 3, + 0, // data + padding + ]), + ); + }); + + test('writeAsciiStr', () => { + encoder.writeAsciiStr('test'); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 4, // length + 116, + 101, + 115, + 116, // 'test' + ]), + ); + }); + }); + + describe('edge cases', () => { + test('encodes 32-bit integer boundaries', () => { + encoder.writeInt(-2147483648); // INT32_MIN + let result = writer.flush(); + expect(result).toEqual(new Uint8Array([128, 0, 0, 0])); + + writer.reset(); + encoder.writeInt(2147483647); // INT32_MAX + result = writer.flush(); + expect(result).toEqual(new Uint8Array([127, 255, 255, 255])); + }); + + test('encodes 32-bit unsigned integer boundaries', () => { + encoder.writeUnsignedInt(0); + let result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + + writer.reset(); + encoder.writeUnsignedInt(4294967295); // UINT32_MAX + result = writer.flush(); + expect(result).toEqual(new Uint8Array([255, 255, 255, 255])); + }); + + test('encodes special float values', () => { + encoder.writeFloat(Infinity); + let result = writer.flush(); + let view = new DataView(result.buffer, result.byteOffset, result.byteLength); + expect(view.getFloat32(0, false)).toBe(Infinity); + + writer.reset(); + encoder.writeFloat(-Infinity); + result = writer.flush(); + view = new DataView(result.buffer, result.byteOffset, result.byteLength); + const negInf = view.getFloat32(0, false); + expect(negInf).toBe(-Infinity); + + writer.reset(); + encoder.writeFloat(NaN); + result = writer.flush(); + view = new DataView(result.buffer, result.byteOffset, result.byteLength); + expect(view.getFloat32(0, false)).toBeNaN(); + }); + + test('encodes special double values', () => { + encoder.writeDouble(Infinity); + let result = writer.flush(); + let view = new DataView(result.buffer, result.byteOffset, result.byteLength); + expect(view.getFloat64(0, false)).toBe(Infinity); + + writer.reset(); + encoder.writeDouble(-Infinity); + result = writer.flush(); + view = new DataView(result.buffer, result.byteOffset, result.byteLength); + const negInf = view.getFloat64(0, false); + expect(negInf).toBe(-Infinity); + + writer.reset(); + encoder.writeDouble(NaN); + result = writer.flush(); + view = new DataView(result.buffer, result.byteOffset, result.byteLength); + expect(view.getFloat64(0, false)).toBeNaN(); + }); + + test('handles very long strings', () => { + const longString = 'a'.repeat(1000); + encoder.writeStr(longString); + const result = writer.flush(); + + // Check length prefix + const view = new DataView(result.buffer); + expect(view.getUint32(0, false)).toBe(1000); + + // Check total length (1000 + padding to 4-byte boundary + 4-byte length prefix) + const expectedPaddedLength = Math.ceil(1000 / 4) * 4; + expect(result.length).toBe(4 + expectedPaddedLength); + }); + }); +}); diff --git a/src/xdr/__tests__/XdrSchemaEncoder.spec.ts b/src/xdr/__tests__/XdrSchemaEncoder.spec.ts new file mode 100644 index 00000000..158d1320 --- /dev/null +++ b/src/xdr/__tests__/XdrSchemaEncoder.spec.ts @@ -0,0 +1,739 @@ +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {XdrSchemaEncoder} from '../XdrSchemaEncoder'; +import {XdrUnion} from '../XdrUnion'; +import type { + XdrSchema, + XdrEnumSchema, + XdrOpaqueSchema, + XdrVarlenOpaqueSchema, + XdrStringSchema, + XdrArraySchema, + XdrVarlenArraySchema, + XdrStructSchema, + XdrUnionSchema, +} from '../types'; + +describe('XdrSchemaEncoder', () => { + let writer: Writer; + let encoder: XdrSchemaEncoder; + + beforeEach(() => { + writer = new Writer(); + encoder = new XdrSchemaEncoder(writer); + }); + + describe('primitive types with schema validation', () => { + test('encodes void with void schema', () => { + const schema: XdrSchema = {type: 'void'}; + const result = encoder.encode(null, schema); + expect(result.length).toBe(0); + }); + + test('throws on non-null with void schema', () => { + const schema: XdrSchema = {type: 'void'}; + // No schema validation, but data validation still applies + expect(() => encoder.writeVoid(schema)).not.toThrow(); + }); + + test('encodes int with int schema', () => { + const schema: XdrSchema = {type: 'int'}; + const result = encoder.encode(42, schema); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('throws on int out of range', () => { + const schema: XdrSchema = {type: 'int'}; + expect(() => encoder.writeInt(2147483648, schema)).toThrow('Value is not a valid 32-bit signed integer'); + expect(() => encoder.writeInt(-2147483649, schema)).toThrow('Value is not a valid 32-bit signed integer'); + }); + + test('encodes unsigned int with unsigned_int schema', () => { + const schema: XdrSchema = {type: 'unsigned_int'}; + const result = encoder.encode(42, schema); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('throws on negative unsigned int', () => { + const schema: XdrSchema = {type: 'unsigned_int'}; + expect(() => encoder.writeUnsignedInt(-1, schema)).toThrow('Value is not a valid 32-bit unsigned integer'); + }); + + test('encodes boolean with boolean schema', () => { + const schema: XdrSchema = {type: 'boolean'}; + let result = encoder.encode(true, schema); + expect(result).toEqual(new Uint8Array([0, 0, 0, 1])); + + result = encoder.encode(false, schema); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); + }); + + test('throws on boolean with non-boolean schema', () => { + const schema: XdrSchema = {type: 'int'}; + // No schema validation, the encoder will just try to write + expect(() => encoder.encode(true, schema)).not.toThrow(); + }); + + test('encodes hyper with hyper schema', () => { + const schema: XdrSchema = {type: 'hyper'}; + const result = encoder.encode(BigInt('0x123456789ABCDEF0'), schema); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); + }); + + test('encodes unsigned hyper with unsigned_hyper schema', () => { + const schema: XdrSchema = {type: 'unsigned_hyper'}; + const result = encoder.encode(BigInt('0x123456789ABCDEF0'), schema); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); + }); + + test('throws on negative unsigned hyper', () => { + const schema: XdrSchema = {type: 'unsigned_hyper'}; + expect(() => encoder.writeUnsignedHyper(-1, schema)).toThrow('Value is not a valid unsigned integer'); + }); + + test('encodes float with float schema', () => { + const schema: XdrSchema = {type: 'float'}; + const result = encoder.encode(3.14159, schema); + expect(result.length).toBe(4); + const view = new DataView(result.buffer); + expect(view.getFloat32(0, false)).toBeCloseTo(3.14159, 5); + }); + + test('encodes double with double schema', () => { + const schema: XdrSchema = {type: 'double'}; + const result = encoder.encode(3.141592653589793, schema); + expect(result.length).toBe(8); + const view = new DataView(result.buffer); + expect(view.getFloat64(0, false)).toBeCloseTo(3.141592653589793, 15); + }); + + test('encodes quadruple with quadruple schema', () => { + const schema: XdrSchema = {type: 'quadruple'}; + expect(() => encoder.encode(3.14159, schema)).toThrow('not implemented'); + }); + }); + + describe('enum schemas', () => { + test('encodes valid enum value', () => { + const schema: XdrEnumSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 2}, + }; + const result = encoder.encode('GREEN', schema); + expect(result).toEqual(new Uint8Array([0, 0, 0, 1])); // GREEN = 1 + }); + + test('throws on invalid enum value', () => { + const schema: XdrEnumSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 2}, + }; + expect(() => encoder.writeEnum('YELLOW', schema)).toThrow('Invalid enum value: YELLOW'); + }); + + test('throws on wrong schema type for enum', () => { + const schema: XdrSchema = {type: 'int'}; + expect(() => encoder.writeEnum('RED', schema as any)).toThrow('Schema is not an enum schema'); + }); + }); + + describe('opaque schemas', () => { + test('encodes opaque data with correct size', () => { + const schema: XdrOpaqueSchema = { + type: 'opaque', + size: 3, + }; + const data = new Uint8Array([1, 2, 3]); + const result = encoder.encode(data, schema); + expect(result).toEqual(new Uint8Array([1, 2, 3, 0])); // padded to 4 bytes + }); + + test('throws on wrong opaque size', () => { + const schema: XdrOpaqueSchema = { + type: 'opaque', + size: 4, + }; + const data = new Uint8Array([1, 2, 3]); + expect(() => encoder.writeOpaque(data, schema)).toThrow('Opaque data length 3 does not match schema size 4'); + }); + + test('encodes variable-length opaque data', () => { + const schema: XdrVarlenOpaqueSchema = { + type: 'vopaque', + size: 10, + }; + const data = new Uint8Array([1, 2, 3]); + const result = encoder.encode(data, schema); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 3, // length + 1, + 2, + 3, + 0, // data + padding + ]), + ); + }); + + test('throws on variable-length opaque data too large', () => { + const schema: XdrVarlenOpaqueSchema = { + type: 'vopaque', + size: 2, + }; + const data = new Uint8Array([1, 2, 3]); + expect(() => encoder.writeVarlenOpaque(data, schema)).toThrow('Opaque data length 3 exceeds maximum size 2'); + }); + }); + + describe('string schemas', () => { + test('encodes string with string schema', () => { + const schema: XdrStringSchema = { + type: 'string', + }; + const result = encoder.encode('hello', schema); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 5, // length + 104, + 101, + 108, + 108, + 111, + 0, + 0, + 0, // 'hello' + padding + ]), + ); + }); + + test('encodes string with size limit', () => { + const schema: XdrStringSchema = { + type: 'string', + size: 10, + }; + const result = encoder.encode('hello', schema); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 5, // length + 104, + 101, + 108, + 108, + 111, + 0, + 0, + 0, // 'hello' + padding + ]), + ); + }); + + test('throws on string too long', () => { + const schema: XdrStringSchema = { + type: 'string', + size: 3, + }; + expect(() => encoder.writeString('hello', schema)).toThrow('String length 5 exceeds maximum size 3'); + }); + }); + + describe('array schemas', () => { + test('encodes fixed-size array', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: {type: 'int'}, + size: 3, + }; + const result = encoder.encode([1, 2, 3], schema); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 1, // 1 + 0, + 0, + 0, + 2, // 2 + 0, + 0, + 0, + 3, // 3 + ]), + ); + }); + + test('throws on wrong array size', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: {type: 'int'}, + size: 3, + }; + expect(() => encoder.writeArray([1, 2], schema)).toThrow('Array length 2 does not match schema size 3'); + }); + + test('encodes variable-length array', () => { + const schema: XdrVarlenArraySchema = { + type: 'varray', + elements: {type: 'int'}, + }; + const result = encoder.encode([1, 2, 3], schema); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 3, // length + 0, + 0, + 0, + 1, // 1 + 0, + 0, + 0, + 2, // 2 + 0, + 0, + 0, + 3, // 3 + ]), + ); + }); + + test('encodes empty variable-length array', () => { + const schema: XdrVarlenArraySchema = { + type: 'varray', + elements: {type: 'int'}, + }; + const result = encoder.encode([], schema); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length + }); + + test('throws on variable-length array too large', () => { + const schema: XdrVarlenArraySchema = { + type: 'varray', + elements: {type: 'int'}, + size: 2, + }; + expect(() => encoder.writeVarlenArray([1, 2, 3], schema)).toThrow('Array length 3 exceeds maximum size 2'); + }); + + test('encodes nested arrays', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: { + type: 'array', + elements: {type: 'int'}, + size: 2, + }, + size: 2, + }; + const result = encoder.encode( + [ + [1, 2], + [3, 4], + ], + schema, + ); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 1, // [1, 2][0] + 0, + 0, + 0, + 2, // [1, 2][1] + 0, + 0, + 0, + 3, // [3, 4][0] + 0, + 0, + 0, + 4, // [3, 4][1] + ]), + ); + }); + }); + + describe('struct schemas', () => { + test('encodes simple struct', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [{type: 'string'}, 'name'], + ], + }; + const result = encoder.encode({id: 42, name: 'test'}, schema); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 42, // id + 0, + 0, + 0, + 4, // name length + 116, + 101, + 115, + 116, // 'test' + ]), + ); + }); + + test('throws on missing required field', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [{type: 'string'}, 'name'], + ], + }; + expect(() => encoder.writeStruct({id: 42}, schema)).toThrow('Missing required field: name'); + }); + + test('encodes nested struct', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [ + { + type: 'struct', + fields: [ + [{type: 'string'}, 'first'], + [{type: 'string'}, 'last'], + ], + }, + 'name', + ], + ], + }; + const result = encoder.encode( + { + id: 42, + name: {first: 'John', last: 'Doe'}, + }, + schema, + ); + + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 42, // id + 0, + 0, + 0, + 4, // first name length + 74, + 111, + 104, + 110, // 'John' + 0, + 0, + 0, + 3, // last name length + 68, + 111, + 101, + 0, // 'Doe' + padding + ]), + ); + }); + + test('encodes empty struct', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [], + }; + const result = encoder.encode({}, schema); + expect(result.length).toBe(0); + }); + }); + + describe('union schemas', () => { + test('encodes union value with numeric discriminant', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + const result = encoder.writeUnion(42, schema, 0); + writer.reset(); + encoder.writeUnion(42, schema, 0); + const encoded = writer.flush(); + expect(encoded).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 0, // discriminant 0 + 0, + 0, + 0, + 42, // value 42 + ]), + ); + }); + + test('encodes union value with boolean discriminant', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [true, {type: 'int'}], + [false, {type: 'string'}], + ], + }; + writer.reset(); + encoder.writeUnion(42, schema, true); + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 1, // discriminant true (1) + 0, + 0, + 0, + 42, // value 42 + ]), + ); + }); + + test('throws on union value with no matching arm', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [[0, {type: 'int'}]], + }; + expect(() => encoder.writeUnion(42, schema, 1)).toThrow('No matching arm found for discriminant: 1'); + }); + + test('encodes union value with default', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [[0, {type: 'int'}]], + default: {type: 'string'}, + }; + writer.reset(); + encoder.writeUnion('hello', schema, 1); // non-matching discriminant, uses default + const result = writer.flush(); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 1, // discriminant 1 + 0, + 0, + 0, + 5, // string length + 104, + 101, + 108, + 108, + 111, + 0, + 0, + 0, // 'hello' + padding + ]), + ); + }); + + test('throws on string discriminant (simplified implementation)', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [['red', {type: 'int'}]], + }; + expect(() => encoder.writeUnion(42, schema, 'red')).toThrow('String discriminants require enum schema context'); + }); + }); + + describe('schema validation during encoding', () => { + test('throws on invalid schema', () => { + const invalidSchema = {type: 'invalid'} as any; + expect(() => encoder.encode(42, invalidSchema)).toThrow('Unknown schema type: invalid'); + }); + + test('throws on value not conforming to schema', () => { + const schema: XdrSchema = {type: 'int'}; + // No automatic schema validation, this will just try to encode + expect(() => encoder.encode('hello', schema)).not.toThrow(); + }); + }); + + describe('typed write methods', () => { + test('writeVoid with schema validation', () => { + const schema: XdrSchema = {type: 'void'}; + encoder.writeVoid(schema); + const result = writer.flush(); + expect(result.length).toBe(0); + }); + + test('writeInt with schema validation', () => { + const schema: XdrSchema = {type: 'int'}; + encoder.writeInt(42, schema); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + }); + + test('writeNumber with different schemas', () => { + // int schema + let schema: XdrSchema = {type: 'int'}; + encoder.writeNumber(42, schema); + let result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + + // unsigned_int schema + writer.reset(); + schema = {type: 'unsigned_int'}; + encoder.writeNumber(42, schema); + result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 42])); + + // float schema + writer.reset(); + schema = {type: 'float'}; + encoder.writeNumber(3.14, schema); + result = writer.flush(); + expect(result.length).toBe(4); + + // double schema + writer.reset(); + schema = {type: 'double'}; + encoder.writeNumber(3.14, schema); + result = writer.flush(); + expect(result.length).toBe(8); + + // hyper schema + writer.reset(); + schema = {type: 'hyper'}; + encoder.writeNumber(42, schema); + result = writer.flush(); + expect(result.length).toBe(8); + + // unsigned_hyper schema + writer.reset(); + schema = {type: 'unsigned_hyper'}; + encoder.writeNumber(42, schema); + result = writer.flush(); + expect(result.length).toBe(8); + + // quadruple schema + writer.reset(); + schema = {type: 'quadruple'}; + expect(() => encoder.writeNumber(3.14, schema)).toThrow('not implemented'); + }); + + test('throws on writeNumber with non-numeric schema', () => { + const schema: XdrSchema = {type: 'string'}; + expect(() => encoder.writeNumber(42, schema)).toThrow('Schema type string is not a numeric type'); + }); + + test('validateSchemaType throws on wrong type', () => { + const schema: XdrSchema = {type: 'string'}; + expect(() => encoder.writeInt(42, schema)).toThrow('Expected schema type int, got string'); + }); + }); + + describe('complex nested schemas', () => { + test('encodes complex nested structure', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [ + { + type: 'varray', + elements: {type: 'string'}, + size: 10, + }, + 'tags', + ], + [ + { + type: 'struct', + fields: [ + [{type: 'string'}, 'name'], + [{type: 'float'}, 'score'], + ], + }, + 'metadata', + ], + ], + }; + + const data = { + id: 123, + tags: ['urgent', 'important'], + metadata: { + name: 'test', + score: 95.5, + }, + }; + + const result = encoder.encode(data, schema); + expect(result.length).toBeGreaterThan(20); // Should be a substantial encoding + + // Verify structure by checking known parts + const view = new DataView(result.buffer); + expect(view.getInt32(0, false)).toBe(123); // id + expect(view.getUint32(4, false)).toBe(2); // tags array length + }); + + test('throws on union encoding without explicit discriminant', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + + // Trying to encode via the generic writeValue method should throw + expect(() => encoder.encode(42, schema)).toThrow('Union values must be wrapped in XdrUnion class'); + }); + + test('encodes union using XdrUnion class', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + + const unionValue = new XdrUnion(0, 42); + const result = encoder.encode(unionValue, schema); + + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 0, // discriminant 0 + 0, + 0, + 0, + 42, // value 42 + ]), + ); + }); + }); +}); diff --git a/src/xdr/__tests__/XdrSchemaValidator.spec.ts b/src/xdr/__tests__/XdrSchemaValidator.spec.ts new file mode 100644 index 00000000..5bf5f7c8 --- /dev/null +++ b/src/xdr/__tests__/XdrSchemaValidator.spec.ts @@ -0,0 +1,574 @@ +import {XdrSchemaValidator} from '../XdrSchemaValidator'; +import type { + XdrSchema, + XdrEnumSchema, + XdrOpaqueSchema, + XdrVarlenOpaqueSchema, + XdrStringSchema, + XdrArraySchema, + XdrVarlenArraySchema, + XdrStructSchema, + XdrUnionSchema, +} from '../types'; + +describe('XdrSchemaValidator', () => { + let validator: XdrSchemaValidator; + + beforeEach(() => { + validator = new XdrSchemaValidator(); + }); + + describe('primitive schemas', () => { + test('validates void schema', () => { + const schema: XdrSchema = {type: 'void'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates int schema', () => { + const schema: XdrSchema = {type: 'int'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates unsigned_int schema', () => { + const schema: XdrSchema = {type: 'unsigned_int'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates boolean schema', () => { + const schema: XdrSchema = {type: 'boolean'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates hyper schema', () => { + const schema: XdrSchema = {type: 'hyper'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates unsigned_hyper schema', () => { + const schema: XdrSchema = {type: 'unsigned_hyper'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates float schema', () => { + const schema: XdrSchema = {type: 'float'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates double schema', () => { + const schema: XdrSchema = {type: 'double'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates quadruple schema', () => { + const schema: XdrSchema = {type: 'quadruple'}; + expect(validator.validateSchema(schema)).toBe(true); + }); + }); + + describe('enum schemas', () => { + test('validates simple enum schema', () => { + const schema: XdrEnumSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 2}, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects enum without values', () => { + const schema = {type: 'enum'} as any; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects enum with duplicate values', () => { + const schema: XdrEnumSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 1}, // duplicate value + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects enum with non-integer values', () => { + const schema: XdrEnumSchema = { + type: 'enum', + values: {RED: 0.5, GREEN: 1, BLUE: 2}, // non-integer + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + }); + + describe('opaque schemas', () => { + test('validates simple opaque schema', () => { + const schema: XdrOpaqueSchema = { + type: 'opaque', + size: 10, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects opaque with negative size', () => { + const schema: XdrOpaqueSchema = { + type: 'opaque', + size: -1, + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects opaque with non-integer size', () => { + const schema: XdrOpaqueSchema = { + type: 'opaque', + size: 10.5, + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('validates variable-length opaque schema', () => { + const schema: XdrVarlenOpaqueSchema = { + type: 'vopaque', + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates variable-length opaque schema with size limit', () => { + const schema: XdrVarlenOpaqueSchema = { + type: 'vopaque', + size: 100, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects variable-length opaque with negative size', () => { + const schema: XdrVarlenOpaqueSchema = { + type: 'vopaque', + size: -1, + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + }); + + describe('string schemas', () => { + test('validates simple string schema', () => { + const schema: XdrStringSchema = { + type: 'string', + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates string schema with size limit', () => { + const schema: XdrStringSchema = { + type: 'string', + size: 50, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects string with negative size', () => { + const schema: XdrStringSchema = { + type: 'string', + size: -1, + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + }); + + describe('array schemas', () => { + test('validates simple array schema', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: {type: 'int'}, + size: 10, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates nested array schema', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: { + type: 'array', + elements: {type: 'int'}, + size: 5, + }, + size: 3, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects array without elements schema', () => { + const schema = { + type: 'array', + size: 10, + } as any; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects array with negative size', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: {type: 'int'}, + size: -1, + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('validates variable-length array schema', () => { + const schema: XdrVarlenArraySchema = { + type: 'varray', + elements: {type: 'string'}, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates variable-length array schema with size limit', () => { + const schema: XdrVarlenArraySchema = { + type: 'varray', + elements: {type: 'int'}, + size: 100, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + }); + + describe('struct schemas', () => { + test('validates simple struct schema', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [{type: 'string'}, 'name'], + ], + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates empty struct schema', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [], + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates nested struct schema', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [ + { + type: 'struct', + fields: [ + [{type: 'string'}, 'first'], + [{type: 'string'}, 'last'], + ], + }, + 'name', + ], + ], + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects struct without fields', () => { + const schema = {type: 'struct'} as any; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects struct with duplicate field names', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [{type: 'string'}, 'id'], // duplicate field name + ], + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects struct with invalid field format', () => { + const schema = { + type: 'struct', + fields: [ + [{type: 'int'}], // missing field name + ], + } as any; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects struct with empty field name', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, ''], // empty field name + ], + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + }); + + describe('union schemas', () => { + test('validates simple union schema', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates union schema with default', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + default: {type: 'void'}, + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('validates union with different discriminant types', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + ['red', {type: 'string'}], + [true, {type: 'boolean'}], + ], + }; + expect(validator.validateSchema(schema)).toBe(true); + }); + + test('rejects empty union', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [], + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects union with duplicate discriminants', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [0, {type: 'string'}], // duplicate discriminant + ], + }; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects union with invalid arm format', () => { + const schema = { + type: 'union', + arms: [ + [0], // missing arm schema + ], + } as any; + expect(validator.validateSchema(schema)).toBe(false); + }); + + test('rejects union with invalid default schema', () => { + const schema = { + type: 'union', + arms: [[0, {type: 'int'}]], + default: {type: 'invalid'}, + } as any; + expect(validator.validateSchema(schema)).toBe(false); + }); + }); + + describe('invalid schemas', () => { + test('rejects null schema', () => { + expect(validator.validateSchema(null as any)).toBe(false); + }); + + test('rejects undefined schema', () => { + expect(validator.validateSchema(undefined as any)).toBe(false); + }); + + test('rejects schema without type', () => { + expect(validator.validateSchema({} as any)).toBe(false); + }); + + test('rejects schema with invalid type', () => { + expect(validator.validateSchema({type: 'invalid'} as any)).toBe(false); + }); + + test('rejects non-object schema', () => { + expect(validator.validateSchema('string' as any)).toBe(false); + }); + }); + + describe('value validation', () => { + test('validates void values', () => { + const schema: XdrSchema = {type: 'void'}; + expect(validator.validateValue(null, schema)).toBe(true); + expect(validator.validateValue(undefined, schema)).toBe(true); + expect(validator.validateValue(42, schema)).toBe(false); + }); + + test('validates int values', () => { + const schema: XdrSchema = {type: 'int'}; + expect(validator.validateValue(42, schema)).toBe(true); + expect(validator.validateValue(-2147483648, schema)).toBe(true); + expect(validator.validateValue(2147483647, schema)).toBe(true); + expect(validator.validateValue(2147483648, schema)).toBe(false); // out of range + expect(validator.validateValue(-2147483649, schema)).toBe(false); // out of range + expect(validator.validateValue(3.14, schema)).toBe(false); // not integer + expect(validator.validateValue('42', schema)).toBe(false); // not number + }); + + test('validates unsigned_int values', () => { + const schema: XdrSchema = {type: 'unsigned_int'}; + expect(validator.validateValue(42, schema)).toBe(true); + expect(validator.validateValue(0, schema)).toBe(true); + expect(validator.validateValue(4294967295, schema)).toBe(true); + expect(validator.validateValue(-1, schema)).toBe(false); // negative + expect(validator.validateValue(4294967296, schema)).toBe(false); // out of range + }); + + test('validates boolean values', () => { + const schema: XdrSchema = {type: 'boolean'}; + expect(validator.validateValue(true, schema)).toBe(true); + expect(validator.validateValue(false, schema)).toBe(true); + expect(validator.validateValue(0, schema)).toBe(false); + expect(validator.validateValue('true', schema)).toBe(false); + }); + + test('validates hyper values', () => { + const schema: XdrSchema = {type: 'hyper'}; + expect(validator.validateValue(42, schema)).toBe(true); + expect(validator.validateValue(BigInt(123), schema)).toBe(true); + expect(validator.validateValue(BigInt(-123), schema)).toBe(true); + expect(validator.validateValue(3.14, schema)).toBe(false); // not integer + expect(validator.validateValue('42', schema)).toBe(false); + }); + + test('validates unsigned_hyper values', () => { + const schema: XdrSchema = {type: 'unsigned_hyper'}; + expect(validator.validateValue(42, schema)).toBe(true); + expect(validator.validateValue(BigInt(123), schema)).toBe(true); + expect(validator.validateValue(0, schema)).toBe(true); + expect(validator.validateValue(BigInt(0), schema)).toBe(true); + expect(validator.validateValue(-1, schema)).toBe(false); // negative + expect(validator.validateValue(BigInt(-123), schema)).toBe(false); // negative + }); + + test('validates float values', () => { + const schema: XdrSchema = {type: 'float'}; + expect(validator.validateValue(3.14, schema)).toBe(true); + expect(validator.validateValue(42, schema)).toBe(true); // integers are valid floats + expect(validator.validateValue(Infinity, schema)).toBe(true); + expect(validator.validateValue(NaN, schema)).toBe(true); + expect(validator.validateValue('3.14', schema)).toBe(false); + }); + + test('validates enum values', () => { + const schema: XdrEnumSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 2}, + }; + expect(validator.validateValue('RED', schema)).toBe(true); + expect(validator.validateValue('GREEN', schema)).toBe(true); + expect(validator.validateValue('YELLOW', schema)).toBe(false); // not in enum + expect(validator.validateValue(0, schema)).toBe(false); // not string + }); + + test('validates opaque values', () => { + const schema: XdrOpaqueSchema = { + type: 'opaque', + size: 4, + }; + expect(validator.validateValue(new Uint8Array([1, 2, 3, 4]), schema)).toBe(true); + expect(validator.validateValue(new Uint8Array([1, 2, 3]), schema)).toBe(false); // wrong size + expect(validator.validateValue([1, 2, 3, 4], schema)).toBe(false); // not Uint8Array + }); + + test('validates variable-length opaque values', () => { + const schema: XdrVarlenOpaqueSchema = { + type: 'vopaque', + size: 10, + }; + expect(validator.validateValue(new Uint8Array([1, 2, 3]), schema)).toBe(true); + expect(validator.validateValue(new Uint8Array(10), schema)).toBe(true); + expect(validator.validateValue(new Uint8Array(11), schema)).toBe(false); // too large + }); + + test('validates string values', () => { + const schema: XdrStringSchema = { + type: 'string', + size: 10, + }; + expect(validator.validateValue('hello', schema)).toBe(true); + expect(validator.validateValue('', schema)).toBe(true); + expect(validator.validateValue('this is too long', schema)).toBe(false); // too long + expect(validator.validateValue(42, schema)).toBe(false); // not string + }); + + test('validates array values', () => { + const schema: XdrArraySchema = { + type: 'array', + elements: {type: 'int'}, + size: 3, + }; + expect(validator.validateValue([1, 2, 3], schema)).toBe(true); + expect(validator.validateValue([1, 2], schema)).toBe(false); // wrong size + expect(validator.validateValue([1, 2, 3, 4], schema)).toBe(false); // wrong size + expect(validator.validateValue([1, 'hello', 3], schema)).toBe(false); // wrong element type + }); + + test('validates variable-length array values', () => { + const schema: XdrVarlenArraySchema = { + type: 'varray', + elements: {type: 'int'}, + size: 5, + }; + expect(validator.validateValue([1, 2, 3], schema)).toBe(true); + expect(validator.validateValue([], schema)).toBe(true); + expect(validator.validateValue([1, 2, 3, 4, 5], schema)).toBe(true); + expect(validator.validateValue([1, 2, 3, 4, 5, 6], schema)).toBe(false); // too large + }); + + test('validates struct values', () => { + const schema: XdrStructSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [{type: 'string'}, 'name'], + ], + }; + expect(validator.validateValue({id: 42, name: 'test'}, schema)).toBe(true); + expect(validator.validateValue({id: 42}, schema)).toBe(false); // missing field + expect(validator.validateValue({id: 'hello', name: 'test'}, schema)).toBe(false); // wrong type + expect(validator.validateValue(null, schema)).toBe(false); // not object + expect(validator.validateValue([42, 'test'], schema)).toBe(false); // array not object + }); + + test('validates union values', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + expect(validator.validateValue(42, schema)).toBe(true); // matches int arm + expect(validator.validateValue('hello', schema)).toBe(true); // matches string arm + expect(validator.validateValue(true, schema)).toBe(false); // matches no arm + }); + + test('validates union values with default', () => { + const schema: XdrUnionSchema = { + type: 'union', + arms: [[0, {type: 'int'}]], + default: {type: 'string'}, + }; + expect(validator.validateValue(42, schema)).toBe(true); // matches int arm + expect(validator.validateValue('hello', schema)).toBe(true); // matches default + expect(validator.validateValue(true, schema)).toBe(false); // matches neither + }); + }); +}); diff --git a/src/xdr/index.ts b/src/xdr/index.ts index 71e07e07..46950b30 100644 --- a/src/xdr/index.ts +++ b/src/xdr/index.ts @@ -1,8 +1,12 @@ /** * XDR (External Data Representation Standard) module * - * This module provides TypeScript type definitions for XDR schemas - * based on RFC 4506 specification. + * This module provides TypeScript type definitions and encoder implementations + * for XDR schemas based on RFC 4506 specification. */ export * from './types'; +export * from './XdrEncoder'; +export * from './XdrSchemaEncoder'; +export * from './XdrSchemaValidator'; +export * from './XdrUnion';