From c8249f8898d847842e7f2cd64e7831707dde1037 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:14:04 +0000 Subject: [PATCH 1/4] Initial plan From 55c1c4ae9925e9e961960c7fda636d1a110b8d6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 10:26:51 +0000 Subject: [PATCH 2/4] feat: implement XDR encoder with schema validation and comprehensive tests Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/xdr/XdrEncoder.ts | 323 +++++++++++ src/xdr/XdrSchemaEncoder.ts | 380 ++++++++++++ src/xdr/XdrSchemaValidator.ts | 284 +++++++++ src/xdr/__tests__/XdrEncoder.spec.ts | 408 +++++++++++++ src/xdr/__tests__/XdrSchemaEncoder.spec.ts | 570 ++++++++++++++++++ src/xdr/__tests__/XdrSchemaValidator.spec.ts | 575 +++++++++++++++++++ src/xdr/index.ts | 7 +- 7 files changed, 2545 insertions(+), 2 deletions(-) create mode 100644 src/xdr/XdrEncoder.ts create mode 100644 src/xdr/XdrSchemaEncoder.ts create mode 100644 src/xdr/XdrSchemaValidator.ts create mode 100644 src/xdr/__tests__/XdrEncoder.spec.ts create mode 100644 src/xdr/__tests__/XdrSchemaEncoder.spec.ts create mode 100644 src/xdr/__tests__/XdrSchemaValidator.spec.ts diff --git a/src/xdr/XdrEncoder.ts b/src/xdr/XdrEncoder.ts new file mode 100644 index 00000000..418ac7d7 --- /dev/null +++ b/src/xdr/XdrEncoder.ts @@ -0,0 +1,323 @@ +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 Object: + return this.writeObj(value as Record); + case Array: + return this.writeArr(value as unknown[]); + 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') { + // Convert bigint to two 32-bit values for big-endian encoding + const high = Number((hyper >> BigInt(32)) & BigInt(0xFFFFFFFF)); + const low = Number(hyper & BigInt(0xFFFFFFFF)); + writer.view.setInt32(writer.x, high, false); // high 32 bits + writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + } 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') { + // Convert bigint to two 32-bit values for big-endian encoding + const high = Number((uhyper >> BigInt(32)) & BigInt(0xFFFFFFFF)); + const low = Number(uhyper & BigInt(0xFFFFFFFF)); + writer.view.setUint32(writer.x, high, false); // high 32 bits + writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + } 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, so this is a placeholder. + */ + public writeQuadruple(quad: number): void { + // Write as two doubles for now (this is not standard XDR) + this.writeDouble(quad); + this.writeDouble(0); // padding + } + + /** + * Writes XDR opaque data with fixed length. + * Data is padded to 4-byte boundary. + */ + public writeOpaque(data: Uint8Array, size: number): void { + if (data.length !== size) { + throw new Error(`Opaque data length ${data.length} does not match expected size ${size}`); + } + + const writer = this.writer; + const paddedSize = this.getPaddedSize(size); + 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); + + const writer = this.writer; + const paddedSize = this.getPaddedSize(data.length); + writer.ensureCapacity(paddedSize); + + // Write data + writer.buf(data, data.length); + + // Write padding bytes + const padding = paddedSize - data.length; + for (let i = 0; i < padding; i++) { + writer.u8(0); + } + } + + /** + * 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; + const encoder = new TextEncoder(); + const utf8Bytes = encoder.encode(str); + + // Write length + this.writeUnsignedInt(utf8Bytes.length); + + // Write string data with padding + const paddedSize = this.getPaddedSize(utf8Bytes.length); + writer.ensureCapacity(paddedSize); + + // Write UTF-8 bytes + writer.buf(utf8Bytes, utf8Bytes.length); + + // Write padding bytes + const padding = paddedSize - utf8Bytes.length; + for (let i = 0; i < padding; i++) { + writer.u8(0); + } + } + + /** + * Writes XDR variable-length array. + * Length is written first, followed by array elements. + */ + public writeArr(arr: unknown[]): void { + this.writeUnsignedInt(arr.length); + for (const item of arr) { + this.writeAny(item); + } + } + + /** + * Writes XDR structure as a simple mapping (not standard XDR, for compatibility). + */ + public writeObj(obj: Record): void { + const entries = Object.entries(obj); + this.writeUnsignedInt(entries.length); + for (const [key, value] of entries) { + this.writeStr(key); + this.writeAny(value); + } + } + + // 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); + } + + /** + * Calculates the padded size for 4-byte alignment. + */ + private getPaddedSize(size: number): number { + return Math.ceil(size / 4) * 4; + } +} \ No newline at end of file diff --git a/src/xdr/XdrSchemaEncoder.ts b/src/xdr/XdrSchemaEncoder.ts new file mode 100644 index 00000000..9ea3cdce --- /dev/null +++ b/src/xdr/XdrSchemaEncoder.ts @@ -0,0 +1,380 @@ +import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib'; +import {XdrEncoder} from './XdrEncoder'; +import {XdrSchemaValidator} from './XdrSchemaValidator'; +import type { + XdrSchema, + XdrEnumSchema, + XdrOpaqueSchema, + XdrVarlenOpaqueSchema, + XdrStringSchema, + XdrArraySchema, + XdrVarlenArraySchema, + XdrStructSchema, + XdrUnionSchema, +} from './types'; + +/** + * XDR binary encoder with schema validation and encoding. + * Encodes values according to provided XDR schemas with proper validation. + * Based on RFC 4506 specification. + */ +export class XdrSchemaEncoder { + private encoder: XdrEncoder; + private validator: XdrSchemaValidator; + + constructor(public readonly writer: IWriter & IWriterGrowable) { + this.encoder = new XdrEncoder(writer); + this.validator = new XdrSchemaValidator(); + } + + /** + * Encodes a value according to the provided schema. + */ + public encode(value: unknown, schema: XdrSchema): Uint8Array { + this.writer.reset(); + + // Validate schema first + if (!this.validator.validateSchema(schema)) { + throw new Error('Invalid XDR schema'); + } + + // Validate value against schema + if (!this.validator.validateValue(value, schema)) { + throw new Error('Value does not conform to schema'); + } + + this.writeValue(value, schema); + return this.writer.flush(); + } + + /** + * Writes a void value with schema validation. + */ + public writeVoid(schema: XdrSchema): void { + this.validateSchemaType(schema, 'void'); + this.encoder.writeVoid(); + } + + /** + * Writes an int value with schema validation. + */ + 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); + } + + /** + * Writes an unsigned int value with schema validation. + */ + 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); + } + + /** + * Writes a boolean value with schema validation. + */ + public writeBoolean(value: boolean, schema: XdrSchema): void { + this.validateSchemaType(schema, 'boolean'); + this.encoder.writeBoolean(value); + } + + /** + * Writes a hyper value with schema validation. + */ + public writeHyper(value: number | bigint, schema: XdrSchema): void { + this.validateSchemaType(schema, 'hyper'); + this.encoder.writeHyper(value); + } + + /** + * Writes an unsigned hyper value with schema validation. + */ + 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); + } + + /** + * Writes a float value with schema validation. + */ + public writeFloat(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'float'); + this.encoder.writeFloat(value); + } + + /** + * Writes a double value with schema validation. + */ + public writeDouble(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'double'); + this.encoder.writeDouble(value); + } + + /** + * Writes a quadruple value with schema validation. + */ + public writeQuadruple(value: number, schema: XdrSchema): void { + this.validateSchemaType(schema, 'quadruple'); + this.encoder.writeQuadruple(value); + } + + /** + * Writes an enum value with schema validation. + */ + 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]); + } + + /** + * Writes opaque data with schema validation. + */ + 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, schema.size); + } + + /** + * Writes variable-length opaque data with schema validation. + */ + 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); + } + + /** + * Writes a string value with schema validation. + */ + 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); + } + + /** + * Writes an array value with schema validation. + */ + 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}`); + } + + // Write array elements without length prefix (fixed-size array) + for (const item of value) { + this.writeValue(item, schema.elements); + } + } + + /** + * Writes a variable-length array value with schema validation. + */ + 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}`); + } + + // Write array length followed by elements + this.encoder.writeUnsignedInt(value.length); + for (const item of value) { + this.writeValue(item, schema.elements); + } + } + + /** + * Writes a struct value with schema validation. + */ + public writeStruct(value: Record, schema: XdrStructSchema): void { + if (schema.type !== 'struct') { + throw new Error('Schema is not a struct schema'); + } + + // Write struct fields in order + for (const [fieldSchema, fieldName] of schema.fields) { + if (!(fieldName in value)) { + throw new Error(`Missing required field: ${fieldName}`); + } + this.writeValue(value[fieldName], fieldSchema); + } + } + + /** + * Writes a union value with schema validation. + */ + public writeUnion(value: unknown, schema: XdrUnionSchema, discriminant: number | string | boolean): void { + if (schema.type !== 'union') { + throw new Error('Schema is not a union schema'); + } + + // Find the matching arm + const arm = schema.arms.find(([armDiscriminant]) => armDiscriminant === discriminant); + if (!arm) { + if (schema.default) { + // Write discriminant and default value + this.writeDiscriminant(discriminant); + this.writeValue(value, schema.default); + } else { + throw new Error(`No matching arm found for discriminant: ${discriminant}`); + } + } else { + // Write discriminant and value according to the arm schema + this.writeDiscriminant(discriminant); + this.writeValue(value, arm[1]); + } + } + + /** + * Generic number writing with schema validation. + */ + 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`); + } + } + + /** + * Writes a value according to its schema. + */ + 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': + // For unions, we need additional context about the discriminant + // This is a simplified implementation + throw new Error('Union encoding requires explicit discriminant. Use writeUnion method instead.'); + 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'); + } + } +} \ No newline at end of file diff --git a/src/xdr/XdrSchemaValidator.ts b/src/xdr/XdrSchemaValidator.ts new file mode 100644 index 00000000..6fb6da67 --- /dev/null +++ b/src/xdr/XdrSchemaValidator.ts @@ -0,0 +1,284 @@ +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; + } + } +} \ No newline at end of file diff --git a/src/xdr/__tests__/XdrEncoder.spec.ts b/src/xdr/__tests__/XdrEncoder.spec.ts new file mode 100644 index 00000000..8528b478 --- /dev/null +++ b/src/xdr/__tests__/XdrEncoder.spec.ts @@ -0,0 +1,408 @@ +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', () => { + encoder.writeQuadruple(3.14159); + const result = writer.flush(); + expect(result.length).toBe(16); // Two doubles for now + }); + }); + + describe('opaque data', () => { + test('encodes fixed opaque data', () => { + const data = new Uint8Array([1, 2, 3]); + encoder.writeOpaque(data, 3); + 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, 4); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); // no padding needed + }); + + test('throws error for mismatched opaque size', () => { + const data = new Uint8Array([1, 2, 3]); + expect(() => encoder.writeOpaque(data, 5)).toThrow('Opaque data length 3 does not match expected size 5'); + }); + + 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('arrays', () => { + test('encodes empty array', () => { + encoder.writeArr([]); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length + }); + + test('encodes array of integers', () => { + encoder.writeArr([1, 2, 3]); + const result = writer.flush(); + 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 array of mixed types', () => { + encoder.writeArr([42, 'test']); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([ + 0, 0, 0, 2, // length + 0, 0, 0, 42, // 42 + 0, 0, 0, 4, // string length + 116, 101, 115, 116 // 'test' + ])); + }); + }); + + describe('objects', () => { + test('encodes empty object', () => { + encoder.writeObj({}); + const result = writer.flush(); + expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length + }); + + test('encodes simple object', () => { + encoder.writeObj({name: 'test', value: 42}); + const result = writer.flush(); + // Note: Object.entries() order may vary, but let's test the structure + expect(result.length).toBeGreaterThan(20); // Should contain length + 2 key-value pairs + + // Check that we have correct length at start + const view = new DataView(result.buffer); + const entryCount = view.getUint32(0, false); + expect(entryCount).toBe(2); + }); + }); + + 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); + }); + }); +}); \ No newline at end of file diff --git a/src/xdr/__tests__/XdrSchemaEncoder.spec.ts b/src/xdr/__tests__/XdrSchemaEncoder.spec.ts new file mode 100644 index 00000000..a66396bc --- /dev/null +++ b/src/xdr/__tests__/XdrSchemaEncoder.spec.ts @@ -0,0 +1,570 @@ +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {XdrSchemaEncoder} from '../XdrSchemaEncoder'; +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'}; + expect(() => encoder.encode(42, schema)).toThrow('Value does not conform to schema'); + }); + + 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'}; + expect(() => encoder.encode(true, schema)).toThrow('Value does not conform to schema'); + }); + + 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'}; + const result = encoder.encode(3.14159, schema); + expect(result.length).toBe(16); // Two doubles for now + }); + }); + + 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('Invalid XDR schema'); + }); + + test('throws on value not conforming to schema', () => { + const schema: XdrSchema = {type: 'int'}; + expect(() => encoder.encode('hello', schema)).toThrow('Value does not conform to schema'); + }); + }); + + 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'}; + encoder.writeNumber(3.14, schema); + result = writer.flush(); + expect(result.length).toBe(16); + }); + + 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 encoding requires explicit discriminant'); + }); + }); +}); \ No newline at end of file diff --git a/src/xdr/__tests__/XdrSchemaValidator.spec.ts b/src/xdr/__tests__/XdrSchemaValidator.spec.ts new file mode 100644 index 00000000..eabe069a --- /dev/null +++ b/src/xdr/__tests__/XdrSchemaValidator.spec.ts @@ -0,0 +1,575 @@ +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 + }); + }); +}); \ No newline at end of file diff --git a/src/xdr/index.ts b/src/xdr/index.ts index 71e07e07..4e604dd7 100644 --- a/src/xdr/index.ts +++ b/src/xdr/index.ts @@ -1,8 +1,11 @@ /** * 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'; From d4705addd45b13bc59d6466cbff70b9033cb66e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:05:59 +0000 Subject: [PATCH 3/4] refactor: implement feedback on XDR encoder - remove schema validation, add XdrUnion, fix quadruple Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/xdr/XdrEncoder.ts | 98 ++--- src/xdr/XdrSchemaEncoder.ts | 94 +--- src/xdr/XdrSchemaValidator.ts | 39 +- src/xdr/XdrUnion.ts | 11 + src/xdr/__tests__/XdrEncoder.spec.ts | 204 +++++---- src/xdr/__tests__/XdrSchemaEncoder.spec.ts | 437 +++++++++++++------ src/xdr/__tests__/XdrSchemaValidator.spec.ts | 131 +++--- src/xdr/index.ts | 1 + 8 files changed, 557 insertions(+), 458 deletions(-) create mode 100644 src/xdr/XdrUnion.ts diff --git a/src/xdr/XdrEncoder.ts b/src/xdr/XdrEncoder.ts index 418ac7d7..b9da1da6 100644 --- a/src/xdr/XdrEncoder.ts +++ b/src/xdr/XdrEncoder.ts @@ -4,7 +4,7 @@ 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 @@ -40,10 +40,6 @@ export class XdrEncoder implements BinaryJsonEncoder { if (value === null) return this.writeVoid(); const constructor = value.constructor; switch (constructor) { - case Object: - return this.writeObj(value as Record); - case Array: - return this.writeArr(value as unknown[]); case Uint8Array: return this.writeBin(value as Uint8Array); default: @@ -106,11 +102,11 @@ export class XdrEncoder implements BinaryJsonEncoder { public writeHyper(hyper: number | bigint): void { const writer = this.writer; writer.ensureCapacity(8); - + if (typeof hyper === 'bigint') { // Convert bigint to two 32-bit values for big-endian encoding - const high = Number((hyper >> BigInt(32)) & BigInt(0xFFFFFFFF)); - const low = Number(hyper & BigInt(0xFFFFFFFF)); + const high = Number((hyper >> BigInt(32)) & BigInt(0xffffffff)); + const low = Number(hyper & BigInt(0xffffffff)); writer.view.setInt32(writer.x, high, false); // high 32 bits writer.view.setUint32(writer.x + 4, low, false); // low 32 bits } else { @@ -129,11 +125,11 @@ export class XdrEncoder implements BinaryJsonEncoder { public writeUnsignedHyper(uhyper: number | bigint): void { const writer = this.writer; writer.ensureCapacity(8); - + if (typeof uhyper === 'bigint') { // Convert bigint to two 32-bit values for big-endian encoding - const high = Number((uhyper >> BigInt(32)) & BigInt(0xFFFFFFFF)); - const low = Number(uhyper & BigInt(0xFFFFFFFF)); + const high = Number((uhyper >> BigInt(32)) & BigInt(0xffffffff)); + const low = Number(uhyper & BigInt(0xffffffff)); writer.view.setUint32(writer.x, high, false); // high 32 bits writer.view.setUint32(writer.x + 4, low, false); // low 32 bits } else { @@ -168,12 +164,10 @@ export class XdrEncoder implements BinaryJsonEncoder { /** * Writes an XDR quadruple value (128-bit float). - * Note: JavaScript doesn't have native 128-bit float support, so this is a placeholder. + * Note: JavaScript doesn't have native 128-bit float support. */ public writeQuadruple(quad: number): void { - // Write as two doubles for now (this is not standard XDR) - this.writeDouble(quad); - this.writeDouble(0); // padding + throw new Error('not implemented'); } /** @@ -184,14 +178,14 @@ export class XdrEncoder implements BinaryJsonEncoder { if (data.length !== size) { throw new Error(`Opaque data length ${data.length} does not match expected size ${size}`); } - + const writer = this.writer; - const paddedSize = this.getPaddedSize(size); + 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++) { @@ -205,14 +199,14 @@ export class XdrEncoder implements BinaryJsonEncoder { */ public writeVarlenOpaque(data: Uint8Array): void { this.writeUnsignedInt(data.length); - + const writer = this.writer; - const paddedSize = this.getPaddedSize(data.length); + const paddedSize = Math.ceil(data.length / 4) * 4; writer.ensureCapacity(paddedSize); - + // Write data writer.buf(data, data.length); - + // Write padding bytes const padding = paddedSize - data.length; for (let i = 0; i < padding; i++) { @@ -226,47 +220,32 @@ export class XdrEncoder implements BinaryJsonEncoder { */ public writeStr(str: string): void { const writer = this.writer; - const encoder = new TextEncoder(); - const utf8Bytes = encoder.encode(str); - - // Write length - this.writeUnsignedInt(utf8Bytes.length); - - // Write string data with padding - const paddedSize = this.getPaddedSize(utf8Bytes.length); - writer.ensureCapacity(paddedSize); - - // Write UTF-8 bytes - writer.buf(utf8Bytes, utf8Bytes.length); - - // Write padding bytes - const padding = paddedSize - utf8Bytes.length; + + // 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; } - /** - * Writes XDR variable-length array. - * Length is written first, followed by array elements. - */ public writeArr(arr: unknown[]): void { - this.writeUnsignedInt(arr.length); - for (const item of arr) { - this.writeAny(item); - } + throw new Error('writeArr not implemented in XDR encoder'); } - /** - * Writes XDR structure as a simple mapping (not standard XDR, for compatibility). - */ public writeObj(obj: Record): void { - const entries = Object.entries(obj); - this.writeUnsignedInt(entries.length); - for (const [key, value] of entries) { - this.writeStr(key); - this.writeAny(value); - } + throw new Error('writeObj not implemented in XDR encoder'); } // BinaryJsonEncoder interface methods @@ -313,11 +292,4 @@ export class XdrEncoder implements BinaryJsonEncoder { public writeAsciiStr(str: string): void { this.writeStr(str); } - - /** - * Calculates the padded size for 4-byte alignment. - */ - private getPaddedSize(size: number): number { - return Math.ceil(size / 4) * 4; - } -} \ No newline at end of file +} diff --git a/src/xdr/XdrSchemaEncoder.ts b/src/xdr/XdrSchemaEncoder.ts index 9ea3cdce..f795fc12 100644 --- a/src/xdr/XdrSchemaEncoder.ts +++ b/src/xdr/XdrSchemaEncoder.ts @@ -1,6 +1,7 @@ import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib'; import {XdrEncoder} from './XdrEncoder'; import {XdrSchemaValidator} from './XdrSchemaValidator'; +import {XdrUnion} from './XdrUnion'; import type { XdrSchema, XdrEnumSchema, @@ -13,11 +14,6 @@ import type { XdrUnionSchema, } from './types'; -/** - * XDR binary encoder with schema validation and encoding. - * Encodes values according to provided XDR schemas with proper validation. - * Based on RFC 4506 specification. - */ export class XdrSchemaEncoder { private encoder: XdrEncoder; private validator: XdrSchemaValidator; @@ -27,37 +23,17 @@ export class XdrSchemaEncoder { this.validator = new XdrSchemaValidator(); } - /** - * Encodes a value according to the provided schema. - */ public encode(value: unknown, schema: XdrSchema): Uint8Array { this.writer.reset(); - - // Validate schema first - if (!this.validator.validateSchema(schema)) { - throw new Error('Invalid XDR schema'); - } - - // Validate value against schema - if (!this.validator.validateValue(value, schema)) { - throw new Error('Value does not conform to schema'); - } - this.writeValue(value, schema); return this.writer.flush(); } - /** - * Writes a void value with schema validation. - */ public writeVoid(schema: XdrSchema): void { this.validateSchemaType(schema, 'void'); this.encoder.writeVoid(); } - /** - * Writes an int value with schema validation. - */ public writeInt(value: number, schema: XdrSchema): void { this.validateSchemaType(schema, 'int'); if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) { @@ -66,9 +42,6 @@ export class XdrSchemaEncoder { this.encoder.writeInt(value); } - /** - * Writes an unsigned int value with schema validation. - */ public writeUnsignedInt(value: number, schema: XdrSchema): void { this.validateSchemaType(schema, 'unsigned_int'); if (!Number.isInteger(value) || value < 0 || value > 4294967295) { @@ -77,25 +50,16 @@ export class XdrSchemaEncoder { this.encoder.writeUnsignedInt(value); } - /** - * Writes a boolean value with schema validation. - */ public writeBoolean(value: boolean, schema: XdrSchema): void { this.validateSchemaType(schema, 'boolean'); this.encoder.writeBoolean(value); } - /** - * Writes a hyper value with schema validation. - */ public writeHyper(value: number | bigint, schema: XdrSchema): void { this.validateSchemaType(schema, 'hyper'); this.encoder.writeHyper(value); } - /** - * Writes an unsigned hyper value with schema validation. - */ 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))) { @@ -104,33 +68,21 @@ export class XdrSchemaEncoder { this.encoder.writeUnsignedHyper(value); } - /** - * Writes a float value with schema validation. - */ public writeFloat(value: number, schema: XdrSchema): void { this.validateSchemaType(schema, 'float'); this.encoder.writeFloat(value); } - /** - * Writes a double value with schema validation. - */ public writeDouble(value: number, schema: XdrSchema): void { this.validateSchemaType(schema, 'double'); this.encoder.writeDouble(value); } - /** - * Writes a quadruple value with schema validation. - */ public writeQuadruple(value: number, schema: XdrSchema): void { this.validateSchemaType(schema, 'quadruple'); this.encoder.writeQuadruple(value); } - /** - * Writes an enum value with schema validation. - */ public writeEnum(value: string, schema: XdrEnumSchema): void { if (schema.type !== 'enum') { throw new Error('Schema is not an enum schema'); @@ -143,9 +95,6 @@ export class XdrSchemaEncoder { this.encoder.writeInt(schema.values[value]); } - /** - * Writes opaque data with schema validation. - */ public writeOpaque(value: Uint8Array, schema: XdrOpaqueSchema): void { if (schema.type !== 'opaque') { throw new Error('Schema is not an opaque schema'); @@ -158,9 +107,6 @@ export class XdrSchemaEncoder { this.encoder.writeOpaque(value, schema.size); } - /** - * Writes variable-length opaque data with schema validation. - */ public writeVarlenOpaque(value: Uint8Array, schema: XdrVarlenOpaqueSchema): void { if (schema.type !== 'vopaque') { throw new Error('Schema is not a variable-length opaque schema'); @@ -173,9 +119,6 @@ export class XdrSchemaEncoder { this.encoder.writeVarlenOpaque(value); } - /** - * Writes a string value with schema validation. - */ public writeString(value: string, schema: XdrStringSchema): void { if (schema.type !== 'string') { throw new Error('Schema is not a string schema'); @@ -188,9 +131,6 @@ export class XdrSchemaEncoder { this.encoder.writeStr(value); } - /** - * Writes an array value with schema validation. - */ public writeArray(value: unknown[], schema: XdrArraySchema): void { if (schema.type !== 'array') { throw new Error('Schema is not an array schema'); @@ -200,15 +140,11 @@ export class XdrSchemaEncoder { throw new Error(`Array length ${value.length} does not match schema size ${schema.size}`); } - // Write array elements without length prefix (fixed-size array) for (const item of value) { this.writeValue(item, schema.elements); } } - /** - * Writes a variable-length array value with schema validation. - */ public writeVarlenArray(value: unknown[], schema: XdrVarlenArraySchema): void { if (schema.type !== 'varray') { throw new Error('Schema is not a variable-length array schema'); @@ -218,22 +154,17 @@ export class XdrSchemaEncoder { throw new Error(`Array length ${value.length} exceeds maximum size ${schema.size}`); } - // Write array length followed by elements this.encoder.writeUnsignedInt(value.length); for (const item of value) { this.writeValue(item, schema.elements); } } - /** - * Writes a struct value with schema validation. - */ public writeStruct(value: Record, schema: XdrStructSchema): void { if (schema.type !== 'struct') { throw new Error('Schema is not a struct schema'); } - // Write struct fields in order for (const [fieldSchema, fieldName] of schema.fields) { if (!(fieldName in value)) { throw new Error(`Missing required field: ${fieldName}`); @@ -242,34 +173,25 @@ export class XdrSchemaEncoder { } } - /** - * Writes a union value with schema validation. - */ public writeUnion(value: unknown, schema: XdrUnionSchema, discriminant: number | string | boolean): void { if (schema.type !== 'union') { throw new Error('Schema is not a union schema'); } - // Find the matching arm const arm = schema.arms.find(([armDiscriminant]) => armDiscriminant === discriminant); if (!arm) { if (schema.default) { - // Write discriminant and default value this.writeDiscriminant(discriminant); this.writeValue(value, schema.default); } else { throw new Error(`No matching arm found for discriminant: ${discriminant}`); } } else { - // Write discriminant and value according to the arm schema this.writeDiscriminant(discriminant); this.writeValue(value, arm[1]); } } - /** - * Generic number writing with schema validation. - */ public writeNumber(value: number, schema: XdrSchema): void { switch (schema.type) { case 'int': @@ -298,9 +220,6 @@ export class XdrSchemaEncoder { } } - /** - * Writes a value according to its schema. - */ private writeValue(value: unknown, schema: XdrSchema): void { switch (schema.type) { case 'void': @@ -352,9 +271,12 @@ export class XdrSchemaEncoder { this.writeStruct(value as Record, schema as XdrStructSchema); break; case 'union': - // For unions, we need additional context about the discriminant - // This is a simplified implementation - throw new Error('Union encoding requires explicit discriminant. Use writeUnion method instead.'); + 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}`); } @@ -377,4 +299,4 @@ export class XdrSchemaEncoder { throw new Error('String discriminants require enum schema context'); } } -} \ No newline at end of file +} diff --git a/src/xdr/XdrSchemaValidator.ts b/src/xdr/XdrSchemaValidator.ts index 6fb6da67..3ce652dc 100644 --- a/src/xdr/XdrSchemaValidator.ts +++ b/src/xdr/XdrSchemaValidator.ts @@ -148,7 +148,7 @@ export class XdrSchemaValidator { } const [fieldSchema, fieldName] = field; - + if (typeof fieldName !== 'string' || fieldName === '') { return false; } @@ -178,7 +178,7 @@ export class XdrSchemaValidator { } const [discriminant, armSchema] = arm; - + // Check for duplicate discriminants if (discriminants.has(discriminant)) { return false; @@ -221,8 +221,10 @@ export class XdrSchemaValidator { 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))); + return ( + (typeof value === 'number' && Number.isInteger(value) && value >= 0) || + (typeof value === 'bigint' && value >= BigInt(0)) + ); case 'float': case 'double': @@ -239,25 +241,27 @@ export class XdrSchemaValidator { case 'vopaque': const vopaqueSchema = schema as XdrVarlenOpaqueSchema; - return value instanceof Uint8Array && - (!vopaqueSchema.size || value.length <= vopaqueSchema.size); + 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); + 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)); + 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)); + 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; @@ -265,8 +269,9 @@ export class XdrSchemaValidator { return false; } const valueObj = value as Record; - return structSchema.fields.every(([fieldSchema, fieldName]) => - fieldName in valueObj && this.validateValueInternal(valueObj[fieldName], fieldSchema) + return structSchema.fields.every( + ([fieldSchema, fieldName]) => + fieldName in valueObj && this.validateValueInternal(valueObj[fieldName], fieldSchema), ); case 'union': @@ -281,4 +286,4 @@ export class XdrSchemaValidator { return false; } } -} \ No newline at end of file +} 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 index 8528b478..fac3efe3 100644 --- a/src/xdr/__tests__/XdrEncoder.spec.ts +++ b/src/xdr/__tests__/XdrEncoder.spec.ts @@ -48,13 +48,13 @@ describe('XdrEncoder', () => { }); test('encodes unsigned int', () => { - encoder.writeUnsignedInt(0xFFFFFFFF); + 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); + encoder.writeHyper(0x123456789abcdef0); const result = writer.flush(); // JavaScript loses precision for large numbers, but we test what we can expect(result.length).toBe(8); @@ -63,7 +63,7 @@ describe('XdrEncoder', () => { 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])); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); }); test('encodes negative hyper from bigint', () => { @@ -75,7 +75,7 @@ describe('XdrEncoder', () => { 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])); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); }); test('encodes float', () => { @@ -97,9 +97,7 @@ describe('XdrEncoder', () => { }); test('encodes quadruple', () => { - encoder.writeQuadruple(3.14159); - const result = writer.flush(); - expect(result.length).toBe(16); // Two doubles for now + expect(() => encoder.writeQuadruple(3.14159)).toThrow('not implemented'); }); }); @@ -127,10 +125,18 @@ describe('XdrEncoder', () => { 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 - ])); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 3, // length + 1, + 2, + 3, + 0, // data + padding + ]), + ); }); test('encodes empty variable-length opaque data', () => { @@ -145,10 +151,22 @@ describe('XdrEncoder', () => { 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 - ])); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 5, // length + 104, + 101, + 108, + 108, + 111, + 0, + 0, + 0, // 'hello' + padding + ]), + ); }); test('encodes empty string', () => { @@ -161,69 +179,39 @@ describe('XdrEncoder', () => { 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 - ])); + 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('arrays', () => { - test('encodes empty array', () => { - encoder.writeArr([]); - const result = writer.flush(); - expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length - }); - - test('encodes array of integers', () => { - encoder.writeArr([1, 2, 3]); - const result = writer.flush(); - 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 array of mixed types', () => { - encoder.writeArr([42, 'test']); - const result = writer.flush(); - expect(result).toEqual(new Uint8Array([ - 0, 0, 0, 2, // length - 0, 0, 0, 42, // 42 - 0, 0, 0, 4, // string length - 116, 101, 115, 116 // 'test' - ])); - }); - }); - - describe('objects', () => { - test('encodes empty object', () => { - encoder.writeObj({}); - const result = writer.flush(); - expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length - }); - - test('encodes simple object', () => { - encoder.writeObj({name: 'test', value: 42}); - const result = writer.flush(); - // Note: Object.entries() order may vary, but let's test the structure - expect(result.length).toBeGreaterThan(20); // Should contain length + 2 key-value pairs - - // Check that we have correct length at start - const view = new DataView(result.buffer); - const entryCount = view.getUint32(0, false); - expect(entryCount).toBe(2); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 4, // length + 116, + 101, + 115, + 116, // 'test' (no padding needed) + ]), + ); }); }); @@ -250,10 +238,18 @@ describe('XdrEncoder', () => { test('handles string', () => { const result = encoder.encode('hi'); - expect(result).toEqual(new Uint8Array([ - 0, 0, 0, 2, // length - 104, 105, 0, 0 // 'hi' + padding - ])); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 2, // length + 104, + 105, + 0, + 0, // 'hi' + padding + ]), + ); }); test('handles bigint', () => { @@ -263,10 +259,18 @@ describe('XdrEncoder', () => { 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 - ])); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 2, // length + 1, + 2, + 0, + 0, // data + padding + ]), + ); }); test('handles unknown types', () => { @@ -312,19 +316,35 @@ describe('XdrEncoder', () => { 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 - ])); + 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' - ])); + expect(result).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 4, // length + 116, + 101, + 115, + 116, // 'test' + ]), + ); }); }); @@ -395,14 +415,14 @@ describe('XdrEncoder', () => { 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); }); }); -}); \ No newline at end of file +}); diff --git a/src/xdr/__tests__/XdrSchemaEncoder.spec.ts b/src/xdr/__tests__/XdrSchemaEncoder.spec.ts index a66396bc..158d1320 100644 --- a/src/xdr/__tests__/XdrSchemaEncoder.spec.ts +++ b/src/xdr/__tests__/XdrSchemaEncoder.spec.ts @@ -1,5 +1,6 @@ import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; import {XdrSchemaEncoder} from '../XdrSchemaEncoder'; +import {XdrUnion} from '../XdrUnion'; import type { XdrSchema, XdrEnumSchema, @@ -30,7 +31,8 @@ describe('XdrSchemaEncoder', () => { test('throws on non-null with void schema', () => { const schema: XdrSchema = {type: 'void'}; - expect(() => encoder.encode(42, schema)).toThrow('Value does not conform to schema'); + // No schema validation, but data validation still applies + expect(() => encoder.writeVoid(schema)).not.toThrow(); }); test('encodes int with int schema', () => { @@ -67,19 +69,20 @@ describe('XdrSchemaEncoder', () => { test('throws on boolean with non-boolean schema', () => { const schema: XdrSchema = {type: 'int'}; - expect(() => encoder.encode(true, schema)).toThrow('Value does not conform to schema'); + // 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])); + 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])); + expect(result).toEqual(new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0])); }); test('throws on negative unsigned hyper', () => { @@ -105,8 +108,7 @@ describe('XdrSchemaEncoder', () => { test('encodes quadruple with quadruple schema', () => { const schema: XdrSchema = {type: 'quadruple'}; - const result = encoder.encode(3.14159, schema); - expect(result.length).toBe(16); // Two doubles for now + expect(() => encoder.encode(3.14159, schema)).toThrow('not implemented'); }); }); @@ -114,7 +116,7 @@ describe('XdrSchemaEncoder', () => { test('encodes valid enum value', () => { const schema: XdrEnumSchema = { type: 'enum', - values: {RED: 0, GREEN: 1, BLUE: 2} + values: {RED: 0, GREEN: 1, BLUE: 2}, }; const result = encoder.encode('GREEN', schema); expect(result).toEqual(new Uint8Array([0, 0, 0, 1])); // GREEN = 1 @@ -123,7 +125,7 @@ describe('XdrSchemaEncoder', () => { test('throws on invalid enum value', () => { const schema: XdrEnumSchema = { type: 'enum', - values: {RED: 0, GREEN: 1, BLUE: 2} + values: {RED: 0, GREEN: 1, BLUE: 2}, }; expect(() => encoder.writeEnum('YELLOW', schema)).toThrow('Invalid enum value: YELLOW'); }); @@ -138,7 +140,7 @@ describe('XdrSchemaEncoder', () => { test('encodes opaque data with correct size', () => { const schema: XdrOpaqueSchema = { type: 'opaque', - size: 3 + size: 3, }; const data = new Uint8Array([1, 2, 3]); const result = encoder.encode(data, schema); @@ -148,7 +150,7 @@ describe('XdrSchemaEncoder', () => { test('throws on wrong opaque size', () => { const schema: XdrOpaqueSchema = { type: 'opaque', - size: 4 + 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'); @@ -157,20 +159,28 @@ describe('XdrSchemaEncoder', () => { test('encodes variable-length opaque data', () => { const schema: XdrVarlenOpaqueSchema = { type: 'vopaque', - size: 10 + 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 - ])); + 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 + size: 2, }; const data = new Uint8Array([1, 2, 3]); expect(() => encoder.writeVarlenOpaque(data, schema)).toThrow('Opaque data length 3 exceeds maximum size 2'); @@ -180,31 +190,55 @@ describe('XdrSchemaEncoder', () => { describe('string schemas', () => { test('encodes string with string schema', () => { const schema: XdrStringSchema = { - type: 'string' + 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 - ])); + 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 + 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 - ])); + 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 + size: 3, }; expect(() => encoder.writeString('hello', schema)).toThrow('String length 5 exceeds maximum size 3'); }); @@ -215,21 +249,32 @@ describe('XdrSchemaEncoder', () => { const schema: XdrArraySchema = { type: 'array', elements: {type: 'int'}, - size: 3 + 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 - ])); + 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 + size: 3, }; expect(() => encoder.writeArray([1, 2], schema)).toThrow('Array length 2 does not match schema size 3'); }); @@ -237,21 +282,35 @@ describe('XdrSchemaEncoder', () => { test('encodes variable-length array', () => { const schema: XdrVarlenArraySchema = { type: 'varray', - elements: {type: 'int'} + 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 - ])); + 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'} + elements: {type: 'int'}, }; const result = encoder.encode([], schema); expect(result).toEqual(new Uint8Array([0, 0, 0, 0])); // just length @@ -261,7 +320,7 @@ describe('XdrSchemaEncoder', () => { const schema: XdrVarlenArraySchema = { type: 'varray', elements: {type: 'int'}, - size: 2 + size: 2, }; expect(() => encoder.writeVarlenArray([1, 2, 3], schema)).toThrow('Array length 3 exceeds maximum size 2'); }); @@ -272,17 +331,37 @@ describe('XdrSchemaEncoder', () => { elements: { type: 'array', elements: {type: 'int'}, - size: 2 + size: 2, }, - 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] - ])); + 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] + ]), + ); }); }); @@ -292,15 +371,26 @@ describe('XdrSchemaEncoder', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{type: 'string'}, 'name'] - ] + [{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' - ])); + 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', () => { @@ -308,8 +398,8 @@ describe('XdrSchemaEncoder', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{type: 'string'}, 'name'] - ] + [{type: 'string'}, 'name'], + ], }; expect(() => encoder.writeStruct({id: 42}, schema)).toThrow('Missing required field: name'); }); @@ -319,33 +409,56 @@ describe('XdrSchemaEncoder', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{ - type: 'struct', - fields: [ - [{type: 'string'}, 'first'], - [{type: 'string'}, 'last'] - ] - }, 'name'] - ] + [ + { + 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 - ])); + 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: [] + fields: [], }; const result = encoder.encode({}, schema); expect(result.length).toBe(0); @@ -358,17 +471,25 @@ describe('XdrSchemaEncoder', () => { type: 'union', arms: [ [0, {type: 'int'}], - [1, {type: 'string'}] - ] + [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 - ])); + expect(encoded).toEqual( + new Uint8Array([ + 0, + 0, + 0, + 0, // discriminant 0 + 0, + 0, + 0, + 42, // value 42 + ]), + ); }); test('encodes union value with boolean discriminant', () => { @@ -376,24 +497,30 @@ describe('XdrSchemaEncoder', () => { type: 'union', arms: [ [true, {type: 'int'}], - [false, {type: 'string'}] - ] + [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 - ])); + 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'}] - ] + arms: [[0, {type: 'int'}]], }; expect(() => encoder.writeUnion(42, schema, 1)).toThrow('No matching arm found for discriminant: 1'); }); @@ -401,27 +528,38 @@ describe('XdrSchemaEncoder', () => { test('encodes union value with default', () => { const schema: XdrUnionSchema = { type: 'union', - arms: [ - [0, {type: 'int'}] - ], - default: {type: 'string'} + 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 - ])); + 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'}] - ] + arms: [['red', {type: 'int'}]], }; expect(() => encoder.writeUnion(42, schema, 'red')).toThrow('String discriminants require enum schema context'); }); @@ -430,12 +568,13 @@ describe('XdrSchemaEncoder', () => { describe('schema validation during encoding', () => { test('throws on invalid schema', () => { const invalidSchema = {type: 'invalid'} as any; - expect(() => encoder.encode(42, invalidSchema)).toThrow('Invalid XDR schema'); + expect(() => encoder.encode(42, invalidSchema)).toThrow('Unknown schema type: invalid'); }); test('throws on value not conforming to schema', () => { const schema: XdrSchema = {type: 'int'}; - expect(() => encoder.encode('hello', schema)).toThrow('Value does not conform to schema'); + // No automatic schema validation, this will just try to encode + expect(() => encoder.encode('hello', schema)).not.toThrow(); }); }); @@ -499,9 +638,7 @@ describe('XdrSchemaEncoder', () => { // quadruple schema writer.reset(); schema = {type: 'quadruple'}; - encoder.writeNumber(3.14, schema); - result = writer.flush(); - expect(result.length).toBe(16); + expect(() => encoder.writeNumber(3.14, schema)).toThrow('not implemented'); }); test('throws on writeNumber with non-numeric schema', () => { @@ -521,19 +658,25 @@ describe('XdrSchemaEncoder', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{ - type: 'varray', - elements: {type: 'string'}, - size: 10 - }, 'tags'], - [{ - type: 'struct', - fields: [ - [{type: 'string'}, 'name'], - [{type: 'float'}, 'score'] - ] - }, 'metadata'] - ] + [ + { + type: 'varray', + elements: {type: 'string'}, + size: 10, + }, + 'tags', + ], + [ + { + type: 'struct', + fields: [ + [{type: 'string'}, 'name'], + [{type: 'float'}, 'score'], + ], + }, + 'metadata', + ], + ], }; const data = { @@ -541,8 +684,8 @@ describe('XdrSchemaEncoder', () => { tags: ['urgent', 'important'], metadata: { name: 'test', - score: 95.5 - } + score: 95.5, + }, }; const result = encoder.encode(data, schema); @@ -559,12 +702,38 @@ describe('XdrSchemaEncoder', () => { type: 'union', arms: [ [0, {type: 'int'}], - [1, {type: 'string'}] - ] + [1, {type: 'string'}], + ], }; - + // Trying to encode via the generic writeValue method should throw - expect(() => encoder.encode(42, schema)).toThrow('Union encoding requires explicit discriminant'); + 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 + ]), + ); }); }); -}); \ No newline at end of file +}); diff --git a/src/xdr/__tests__/XdrSchemaValidator.spec.ts b/src/xdr/__tests__/XdrSchemaValidator.spec.ts index eabe069a..5bf5f7c8 100644 --- a/src/xdr/__tests__/XdrSchemaValidator.spec.ts +++ b/src/xdr/__tests__/XdrSchemaValidator.spec.ts @@ -69,7 +69,7 @@ describe('XdrSchemaValidator', () => { test('validates simple enum schema', () => { const schema: XdrEnumSchema = { type: 'enum', - values: {RED: 0, GREEN: 1, BLUE: 2} + values: {RED: 0, GREEN: 1, BLUE: 2}, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -82,7 +82,7 @@ describe('XdrSchemaValidator', () => { test('rejects enum with duplicate values', () => { const schema: XdrEnumSchema = { type: 'enum', - values: {RED: 0, GREEN: 1, BLUE: 1} // duplicate value + values: {RED: 0, GREEN: 1, BLUE: 1}, // duplicate value }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -90,7 +90,7 @@ describe('XdrSchemaValidator', () => { test('rejects enum with non-integer values', () => { const schema: XdrEnumSchema = { type: 'enum', - values: {RED: 0.5, GREEN: 1, BLUE: 2} // non-integer + values: {RED: 0.5, GREEN: 1, BLUE: 2}, // non-integer }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -100,7 +100,7 @@ describe('XdrSchemaValidator', () => { test('validates simple opaque schema', () => { const schema: XdrOpaqueSchema = { type: 'opaque', - size: 10 + size: 10, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -108,7 +108,7 @@ describe('XdrSchemaValidator', () => { test('rejects opaque with negative size', () => { const schema: XdrOpaqueSchema = { type: 'opaque', - size: -1 + size: -1, }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -116,14 +116,14 @@ describe('XdrSchemaValidator', () => { test('rejects opaque with non-integer size', () => { const schema: XdrOpaqueSchema = { type: 'opaque', - size: 10.5 + size: 10.5, }; expect(validator.validateSchema(schema)).toBe(false); }); test('validates variable-length opaque schema', () => { const schema: XdrVarlenOpaqueSchema = { - type: 'vopaque' + type: 'vopaque', }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -131,7 +131,7 @@ describe('XdrSchemaValidator', () => { test('validates variable-length opaque schema with size limit', () => { const schema: XdrVarlenOpaqueSchema = { type: 'vopaque', - size: 100 + size: 100, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -139,7 +139,7 @@ describe('XdrSchemaValidator', () => { test('rejects variable-length opaque with negative size', () => { const schema: XdrVarlenOpaqueSchema = { type: 'vopaque', - size: -1 + size: -1, }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -148,7 +148,7 @@ describe('XdrSchemaValidator', () => { describe('string schemas', () => { test('validates simple string schema', () => { const schema: XdrStringSchema = { - type: 'string' + type: 'string', }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -156,7 +156,7 @@ describe('XdrSchemaValidator', () => { test('validates string schema with size limit', () => { const schema: XdrStringSchema = { type: 'string', - size: 50 + size: 50, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -164,7 +164,7 @@ describe('XdrSchemaValidator', () => { test('rejects string with negative size', () => { const schema: XdrStringSchema = { type: 'string', - size: -1 + size: -1, }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -175,7 +175,7 @@ describe('XdrSchemaValidator', () => { const schema: XdrArraySchema = { type: 'array', elements: {type: 'int'}, - size: 10 + size: 10, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -186,9 +186,9 @@ describe('XdrSchemaValidator', () => { elements: { type: 'array', elements: {type: 'int'}, - size: 5 + size: 5, }, - size: 3 + size: 3, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -196,7 +196,7 @@ describe('XdrSchemaValidator', () => { test('rejects array without elements schema', () => { const schema = { type: 'array', - size: 10 + size: 10, } as any; expect(validator.validateSchema(schema)).toBe(false); }); @@ -205,7 +205,7 @@ describe('XdrSchemaValidator', () => { const schema: XdrArraySchema = { type: 'array', elements: {type: 'int'}, - size: -1 + size: -1, }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -213,7 +213,7 @@ describe('XdrSchemaValidator', () => { test('validates variable-length array schema', () => { const schema: XdrVarlenArraySchema = { type: 'varray', - elements: {type: 'string'} + elements: {type: 'string'}, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -222,7 +222,7 @@ describe('XdrSchemaValidator', () => { const schema: XdrVarlenArraySchema = { type: 'varray', elements: {type: 'int'}, - size: 100 + size: 100, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -234,8 +234,8 @@ describe('XdrSchemaValidator', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{type: 'string'}, 'name'] - ] + [{type: 'string'}, 'name'], + ], }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -243,7 +243,7 @@ describe('XdrSchemaValidator', () => { test('validates empty struct schema', () => { const schema: XdrStructSchema = { type: 'struct', - fields: [] + fields: [], }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -253,14 +253,17 @@ describe('XdrSchemaValidator', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{ - type: 'struct', - fields: [ - [{type: 'string'}, 'first'], - [{type: 'string'}, 'last'] - ] - }, 'name'] - ] + [ + { + type: 'struct', + fields: [ + [{type: 'string'}, 'first'], + [{type: 'string'}, 'last'], + ], + }, + 'name', + ], + ], }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -275,8 +278,8 @@ describe('XdrSchemaValidator', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{type: 'string'}, 'id'] // duplicate field name - ] + [{type: 'string'}, 'id'], // duplicate field name + ], }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -285,8 +288,8 @@ describe('XdrSchemaValidator', () => { const schema = { type: 'struct', fields: [ - [{type: 'int'}] // missing field name - ] + [{type: 'int'}], // missing field name + ], } as any; expect(validator.validateSchema(schema)).toBe(false); }); @@ -295,8 +298,8 @@ describe('XdrSchemaValidator', () => { const schema: XdrStructSchema = { type: 'struct', fields: [ - [{type: 'int'}, ''] // empty field name - ] + [{type: 'int'}, ''], // empty field name + ], }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -308,8 +311,8 @@ describe('XdrSchemaValidator', () => { type: 'union', arms: [ [0, {type: 'int'}], - [1, {type: 'string'}] - ] + [1, {type: 'string'}], + ], }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -319,9 +322,9 @@ describe('XdrSchemaValidator', () => { type: 'union', arms: [ [0, {type: 'int'}], - [1, {type: 'string'}] + [1, {type: 'string'}], ], - default: {type: 'void'} + default: {type: 'void'}, }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -332,8 +335,8 @@ describe('XdrSchemaValidator', () => { arms: [ [0, {type: 'int'}], ['red', {type: 'string'}], - [true, {type: 'boolean'}] - ] + [true, {type: 'boolean'}], + ], }; expect(validator.validateSchema(schema)).toBe(true); }); @@ -341,7 +344,7 @@ describe('XdrSchemaValidator', () => { test('rejects empty union', () => { const schema: XdrUnionSchema = { type: 'union', - arms: [] + arms: [], }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -351,8 +354,8 @@ describe('XdrSchemaValidator', () => { type: 'union', arms: [ [0, {type: 'int'}], - [0, {type: 'string'}] // duplicate discriminant - ] + [0, {type: 'string'}], // duplicate discriminant + ], }; expect(validator.validateSchema(schema)).toBe(false); }); @@ -361,8 +364,8 @@ describe('XdrSchemaValidator', () => { const schema = { type: 'union', arms: [ - [0] // missing arm schema - ] + [0], // missing arm schema + ], } as any; expect(validator.validateSchema(schema)).toBe(false); }); @@ -370,10 +373,8 @@ describe('XdrSchemaValidator', () => { test('rejects union with invalid default schema', () => { const schema = { type: 'union', - arms: [ - [0, {type: 'int'}] - ], - default: {type: 'invalid'} + arms: [[0, {type: 'int'}]], + default: {type: 'invalid'}, } as any; expect(validator.validateSchema(schema)).toBe(false); }); @@ -468,7 +469,7 @@ describe('XdrSchemaValidator', () => { test('validates enum values', () => { const schema: XdrEnumSchema = { type: 'enum', - values: {RED: 0, GREEN: 1, BLUE: 2} + values: {RED: 0, GREEN: 1, BLUE: 2}, }; expect(validator.validateValue('RED', schema)).toBe(true); expect(validator.validateValue('GREEN', schema)).toBe(true); @@ -479,7 +480,7 @@ describe('XdrSchemaValidator', () => { test('validates opaque values', () => { const schema: XdrOpaqueSchema = { type: 'opaque', - size: 4 + 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 @@ -489,7 +490,7 @@ describe('XdrSchemaValidator', () => { test('validates variable-length opaque values', () => { const schema: XdrVarlenOpaqueSchema = { type: 'vopaque', - size: 10 + size: 10, }; expect(validator.validateValue(new Uint8Array([1, 2, 3]), schema)).toBe(true); expect(validator.validateValue(new Uint8Array(10), schema)).toBe(true); @@ -499,7 +500,7 @@ describe('XdrSchemaValidator', () => { test('validates string values', () => { const schema: XdrStringSchema = { type: 'string', - size: 10 + size: 10, }; expect(validator.validateValue('hello', schema)).toBe(true); expect(validator.validateValue('', schema)).toBe(true); @@ -511,7 +512,7 @@ describe('XdrSchemaValidator', () => { const schema: XdrArraySchema = { type: 'array', elements: {type: 'int'}, - size: 3 + size: 3, }; expect(validator.validateValue([1, 2, 3], schema)).toBe(true); expect(validator.validateValue([1, 2], schema)).toBe(false); // wrong size @@ -523,7 +524,7 @@ describe('XdrSchemaValidator', () => { const schema: XdrVarlenArraySchema = { type: 'varray', elements: {type: 'int'}, - size: 5 + size: 5, }; expect(validator.validateValue([1, 2, 3], schema)).toBe(true); expect(validator.validateValue([], schema)).toBe(true); @@ -536,8 +537,8 @@ describe('XdrSchemaValidator', () => { type: 'struct', fields: [ [{type: 'int'}, 'id'], - [{type: 'string'}, 'name'] - ] + [{type: 'string'}, 'name'], + ], }; expect(validator.validateValue({id: 42, name: 'test'}, schema)).toBe(true); expect(validator.validateValue({id: 42}, schema)).toBe(false); // missing field @@ -551,8 +552,8 @@ describe('XdrSchemaValidator', () => { type: 'union', arms: [ [0, {type: 'int'}], - [1, {type: 'string'}] - ] + [1, {type: 'string'}], + ], }; expect(validator.validateValue(42, schema)).toBe(true); // matches int arm expect(validator.validateValue('hello', schema)).toBe(true); // matches string arm @@ -562,14 +563,12 @@ describe('XdrSchemaValidator', () => { test('validates union values with default', () => { const schema: XdrUnionSchema = { type: 'union', - arms: [ - [0, {type: 'int'}] - ], - default: {type: 'string'} + 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 }); }); -}); \ No newline at end of file +}); diff --git a/src/xdr/index.ts b/src/xdr/index.ts index 4e604dd7..46950b30 100644 --- a/src/xdr/index.ts +++ b/src/xdr/index.ts @@ -9,3 +9,4 @@ export * from './types'; export * from './XdrEncoder'; export * from './XdrSchemaEncoder'; export * from './XdrSchemaValidator'; +export * from './XdrUnion'; From d10904ed5697e8b869e43c846002cb440019d046 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:49:06 +0000 Subject: [PATCH 4/4] refactor: address review comments - use native BigInt methods, simplify opaque handling, fix error messages Co-authored-by: streamich <9773803+streamich@users.noreply.github.com> --- src/xdr/XdrEncoder.ts | 37 ++++++---------------------- src/xdr/XdrSchemaEncoder.ts | 5 +--- src/xdr/__tests__/XdrEncoder.spec.ts | 9 ++----- 3 files changed, 10 insertions(+), 41 deletions(-) diff --git a/src/xdr/XdrEncoder.ts b/src/xdr/XdrEncoder.ts index b9da1da6..fba2681d 100644 --- a/src/xdr/XdrEncoder.ts +++ b/src/xdr/XdrEncoder.ts @@ -104,11 +104,7 @@ export class XdrEncoder implements BinaryJsonEncoder { writer.ensureCapacity(8); if (typeof hyper === 'bigint') { - // Convert bigint to two 32-bit values for big-endian encoding - const high = Number((hyper >> BigInt(32)) & BigInt(0xffffffff)); - const low = Number(hyper & BigInt(0xffffffff)); - writer.view.setInt32(writer.x, high, false); // high 32 bits - writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + writer.view.setBigInt64(writer.x, hyper, false); // big-endian } else { const truncated = Math.trunc(hyper); const high = Math.floor(truncated / 0x100000000); @@ -127,11 +123,7 @@ export class XdrEncoder implements BinaryJsonEncoder { writer.ensureCapacity(8); if (typeof uhyper === 'bigint') { - // Convert bigint to two 32-bit values for big-endian encoding - const high = Number((uhyper >> BigInt(32)) & BigInt(0xffffffff)); - const low = Number(uhyper & BigInt(0xffffffff)); - writer.view.setUint32(writer.x, high, false); // high 32 bits - writer.view.setUint32(writer.x + 4, low, false); // low 32 bits + writer.view.setBigUint64(writer.x, uhyper, false); // big-endian } else { const truncated = Math.trunc(Math.abs(uhyper)); const high = Math.floor(truncated / 0x100000000); @@ -174,11 +166,8 @@ export class XdrEncoder implements BinaryJsonEncoder { * Writes XDR opaque data with fixed length. * Data is padded to 4-byte boundary. */ - public writeOpaque(data: Uint8Array, size: number): void { - if (data.length !== size) { - throw new Error(`Opaque data length ${data.length} does not match expected size ${size}`); - } - + public writeOpaque(data: Uint8Array): void { + const size = data.length; const writer = this.writer; const paddedSize = Math.ceil(size / 4) * 4; writer.ensureCapacity(paddedSize); @@ -199,19 +188,7 @@ export class XdrEncoder implements BinaryJsonEncoder { */ public writeVarlenOpaque(data: Uint8Array): void { this.writeUnsignedInt(data.length); - - const writer = this.writer; - const paddedSize = Math.ceil(data.length / 4) * 4; - writer.ensureCapacity(paddedSize); - - // Write data - writer.buf(data, data.length); - - // Write padding bytes - const padding = paddedSize - data.length; - for (let i = 0; i < padding; i++) { - writer.u8(0); - } + this.writeOpaque(data); } /** @@ -241,11 +218,11 @@ export class XdrEncoder implements BinaryJsonEncoder { } public writeArr(arr: unknown[]): void { - throw new Error('writeArr not implemented in XDR encoder'); + throw new Error('not implemented'); } public writeObj(obj: Record): void { - throw new Error('writeObj not implemented in XDR encoder'); + throw new Error('not implemented'); } // BinaryJsonEncoder interface methods diff --git a/src/xdr/XdrSchemaEncoder.ts b/src/xdr/XdrSchemaEncoder.ts index f795fc12..ca9c21fa 100644 --- a/src/xdr/XdrSchemaEncoder.ts +++ b/src/xdr/XdrSchemaEncoder.ts @@ -1,6 +1,5 @@ import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib'; import {XdrEncoder} from './XdrEncoder'; -import {XdrSchemaValidator} from './XdrSchemaValidator'; import {XdrUnion} from './XdrUnion'; import type { XdrSchema, @@ -16,11 +15,9 @@ import type { export class XdrSchemaEncoder { private encoder: XdrEncoder; - private validator: XdrSchemaValidator; constructor(public readonly writer: IWriter & IWriterGrowable) { this.encoder = new XdrEncoder(writer); - this.validator = new XdrSchemaValidator(); } public encode(value: unknown, schema: XdrSchema): Uint8Array { @@ -104,7 +101,7 @@ export class XdrSchemaEncoder { throw new Error(`Opaque data length ${value.length} does not match schema size ${schema.size}`); } - this.encoder.writeOpaque(value, schema.size); + this.encoder.writeOpaque(value); } public writeVarlenOpaque(value: Uint8Array, schema: XdrVarlenOpaqueSchema): void { diff --git a/src/xdr/__tests__/XdrEncoder.spec.ts b/src/xdr/__tests__/XdrEncoder.spec.ts index fac3efe3..4f4dc23c 100644 --- a/src/xdr/__tests__/XdrEncoder.spec.ts +++ b/src/xdr/__tests__/XdrEncoder.spec.ts @@ -104,23 +104,18 @@ describe('XdrEncoder', () => { describe('opaque data', () => { test('encodes fixed opaque data', () => { const data = new Uint8Array([1, 2, 3]); - encoder.writeOpaque(data, 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, 4); + encoder.writeOpaque(data); const result = writer.flush(); expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); // no padding needed }); - test('throws error for mismatched opaque size', () => { - const data = new Uint8Array([1, 2, 3]); - expect(() => encoder.writeOpaque(data, 5)).toThrow('Opaque data length 3 does not match expected size 5'); - }); - test('encodes variable-length opaque data', () => { const data = new Uint8Array([1, 2, 3]); encoder.writeVarlenOpaque(data);