diff --git a/src/xdr/XdrDecoder.ts b/src/xdr/XdrDecoder.ts new file mode 100644 index 00000000..6e158d13 --- /dev/null +++ b/src/xdr/XdrDecoder.ts @@ -0,0 +1,199 @@ +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import type {IReader, IReaderResettable} from '@jsonjoy.com/buffers/lib'; +import type {BinaryJsonDecoder} from '../types'; + +/** + * XDR (External Data Representation) binary decoder for basic value decoding. + * Implements XDR binary decoding according to RFC 4506. + * + * Key XDR decoding 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 XdrDecoder + implements BinaryJsonDecoder +{ + public constructor(public reader: R = new Reader() as any) {} + + public read(uint8: Uint8Array): unknown { + this.reader.reset(uint8); + return this.readAny(); + } + + public decode(uint8: Uint8Array): unknown { + this.reader.reset(uint8); + return this.readAny(); + } + + public readAny(): unknown { + // Basic implementation - in practice this would need schema info + // For now, we'll throw as this should be used with schema decoder + throw new Error('XdrDecoder.readAny() requires explicit type methods or use XdrSchemaDecoder'); + } + + /** + * Reads an XDR void value (no data is actually read). + */ + public readVoid(): void { + // Void values have no representation in XDR + } + + /** + * Reads an XDR boolean value as a 4-byte integer. + * Returns true for non-zero values, false for zero. + */ + public readBoolean(): boolean { + return this.readInt() !== 0; + } + + /** + * Reads an XDR signed 32-bit integer in big-endian format. + */ + public readInt(): number { + const reader = this.reader; + const value = reader.view.getInt32(reader.x, false); // false = big-endian + reader.x += 4; + return value; + } + + /** + * Reads an XDR unsigned 32-bit integer in big-endian format. + */ + public readUnsignedInt(): number { + const reader = this.reader; + const value = reader.view.getUint32(reader.x, false); // false = big-endian + reader.x += 4; + return value; + } + + /** + * Reads an XDR signed 64-bit integer (hyper) in big-endian format. + */ + public readHyper(): bigint { + const reader = this.reader; + const value = reader.view.getBigInt64(reader.x, false); // false = big-endian + reader.x += 8; + return value; + } + + /** + * Reads an XDR unsigned 64-bit integer (unsigned hyper) in big-endian format. + */ + public readUnsignedHyper(): bigint { + const reader = this.reader; + const value = reader.view.getBigUint64(reader.x, false); // false = big-endian + reader.x += 8; + return value; + } + + /** + * Reads an XDR float value using IEEE 754 single-precision in big-endian format. + */ + public readFloat(): number { + const reader = this.reader; + const value = reader.view.getFloat32(reader.x, false); // false = big-endian + reader.x += 4; + return value; + } + + /** + * Reads an XDR double value using IEEE 754 double-precision in big-endian format. + */ + public readDouble(): number { + const reader = this.reader; + const value = reader.view.getFloat64(reader.x, false); // false = big-endian + reader.x += 8; + return value; + } + + /** + * Reads an XDR quadruple value (128-bit float). + * Note: JavaScript doesn't have native 128-bit float support. + */ + public readQuadruple(): number { + throw new Error('not implemented'); + } + + /** + * Reads XDR opaque data with known fixed length. + * Data is padded to 4-byte boundary but only the actual data is returned. + */ + public readOpaque(size: number): Uint8Array { + const reader = this.reader; + const data = new Uint8Array(size); + + // Read actual data + for (let i = 0; i < size; i++) { + data[i] = reader.u8(); + } + + // Skip padding bytes to reach 4-byte boundary + const paddedSize = Math.ceil(size / 4) * 4; + const padding = paddedSize - size; + reader.skip(padding); + + return data; + } + + /** + * Reads XDR variable-length opaque data. + * Length is read first, followed by data padded to 4-byte boundary. + */ + public readVarlenOpaque(): Uint8Array { + const size = this.readUnsignedInt(); + return this.readOpaque(size); + } + + /** + * Reads an XDR string with UTF-8 encoding. + * Length is read first, followed by UTF-8 bytes padded to 4-byte boundary. + */ + public readString(): string { + const size = this.readUnsignedInt(); + const reader = this.reader; + + // Read UTF-8 bytes + const utf8Bytes = new Uint8Array(size); + for (let i = 0; i < size; i++) { + utf8Bytes[i] = reader.u8(); + } + + // Skip padding bytes to reach 4-byte boundary + const paddedSize = Math.ceil(size / 4) * 4; + const padding = paddedSize - size; + reader.skip(padding); + + // Decode UTF-8 to string + return new TextDecoder('utf-8').decode(utf8Bytes); + } + + /** + * Reads an XDR enum value as an unsigned integer. + */ + public readEnum(): number { + return this.readInt(); + } + + /** + * Reads a fixed-size array of elements. + * Caller must provide the decode function for each element. + */ + public readArray(size: number, elementReader: () => T): T[] { + const array: T[] = []; + for (let i = 0; i < size; i++) { + array.push(elementReader()); + } + return array; + } + + /** + * Reads a variable-length array of elements. + * Length is read first, followed by elements. + */ + public readVarlenArray(elementReader: () => T): T[] { + const size = this.readUnsignedInt(); + return this.readArray(size, elementReader); + } +} diff --git a/src/xdr/XdrSchemaDecoder.ts b/src/xdr/XdrSchemaDecoder.ts new file mode 100644 index 00000000..ef1910c1 --- /dev/null +++ b/src/xdr/XdrSchemaDecoder.ts @@ -0,0 +1,199 @@ +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import {XdrDecoder} from './XdrDecoder'; +import {XdrUnion} from './XdrUnion'; +import type {IReader, IReaderResettable} from '@jsonjoy.com/buffers/lib'; +import type { + XdrSchema, + XdrPrimitiveSchema, + XdrWidePrimitiveSchema, + XdrCompositeSchema, + XdrEnumSchema, + XdrOpaqueSchema, + XdrVarlenOpaqueSchema, + XdrStringSchema, + XdrArraySchema, + XdrVarlenArraySchema, + XdrStructSchema, + XdrUnionSchema, +} from './types'; + +/** + * XDR (External Data Representation) schema-aware decoder. + * Decodes values according to provided XDR schemas with proper validation. + * Based on RFC 4506 specification. + */ +export class XdrSchemaDecoder { + private decoder: XdrDecoder; + + constructor(public readonly reader: IReader & IReaderResettable = new Reader()) { + this.decoder = new XdrDecoder(reader); + } + + /** + * Decodes a value according to the provided schema. + */ + public decode(data: Uint8Array, schema: XdrSchema): unknown { + this.reader.reset(data); + return this.readValue(schema); + } + + /** + * Reads a value according to its schema. + */ + private readValue(schema: XdrSchema): unknown { + switch (schema.type) { + // Primitive types + case 'void': + return this.decoder.readVoid(); + case 'int': + return this.decoder.readInt(); + case 'unsigned_int': + return this.decoder.readUnsignedInt(); + case 'boolean': + return this.decoder.readBoolean(); + case 'hyper': + return this.decoder.readHyper(); + case 'unsigned_hyper': + return this.decoder.readUnsignedHyper(); + case 'float': + return this.decoder.readFloat(); + case 'double': + return this.decoder.readDouble(); + case 'quadruple': + return this.decoder.readQuadruple(); + case 'enum': + return this.readEnum(schema as XdrEnumSchema); + + // Wide primitive types + case 'opaque': + return this.readOpaque(schema as XdrOpaqueSchema); + case 'vopaque': + return this.readVarlenOpaque(schema as XdrVarlenOpaqueSchema); + case 'string': + return this.readString(schema as XdrStringSchema); + + // Composite types + case 'array': + return this.readArray(schema as XdrArraySchema); + case 'varray': + return this.readVarlenArray(schema as XdrVarlenArraySchema); + case 'struct': + return this.readStruct(schema as XdrStructSchema); + case 'union': + return this.readUnion(schema as XdrUnionSchema); + + default: + throw new Error(`Unknown schema type: ${(schema as any).type}`); + } + } + + /** + * Reads an enum value according to the enum schema. + */ + private readEnum(schema: XdrEnumSchema): string | number { + const value = this.decoder.readEnum(); + + // Find the enum name for this value + for (const [name, enumValue] of Object.entries(schema.values)) { + if (enumValue === value) { + return name; + } + } + + // If no matching name found, return the numeric value + return value; + } + + /** + * Reads opaque data according to the opaque schema. + */ + private readOpaque(schema: XdrOpaqueSchema): Uint8Array { + return this.decoder.readOpaque(schema.size); + } + + /** + * Reads variable-length opaque data according to the schema. + */ + private readVarlenOpaque(schema: XdrVarlenOpaqueSchema): Uint8Array { + const data = this.decoder.readVarlenOpaque(); + + // Check size constraint if specified + if (schema.size !== undefined && data.length > schema.size) { + throw new Error(`Variable-length opaque data size ${data.length} exceeds maximum ${schema.size}`); + } + + return data; + } + + /** + * Reads a string according to the string schema. + */ + private readString(schema: XdrStringSchema): string { + const str = this.decoder.readString(); + + // Check size constraint if specified + if (schema.size !== undefined && str.length > schema.size) { + throw new Error(`String length ${str.length} exceeds maximum ${schema.size}`); + } + + return str; + } + + /** + * Reads a fixed-size array according to the array schema. + */ + private readArray(schema: XdrArraySchema): unknown[] { + return this.decoder.readArray(schema.size, () => this.readValue(schema.elements)); + } + + /** + * Reads a variable-length array according to the schema. + */ + private readVarlenArray(schema: XdrVarlenArraySchema): unknown[] { + const array = this.decoder.readVarlenArray(() => this.readValue(schema.elements)); + + // Check size constraint if specified + if (schema.size !== undefined && array.length > schema.size) { + throw new Error(`Variable-length array size ${array.length} exceeds maximum ${schema.size}`); + } + + return array; + } + + /** + * Reads a struct according to the struct schema. + */ + private readStruct(schema: XdrStructSchema): Record { + const struct: Record = {}; + + for (const [fieldSchema, fieldName] of schema.fields) { + struct[fieldName] = this.readValue(fieldSchema); + } + + return struct; + } + + /** + * Reads a union according to the union schema. + */ + private readUnion(schema: XdrUnionSchema): XdrUnion { + // Read discriminant + const discriminant = this.decoder.readInt(); + + // Find matching arm + for (const [armDiscriminant, armSchema] of schema.arms) { + if (armDiscriminant === discriminant) { + const value = this.readValue(armSchema); + return new XdrUnion(discriminant, value); + } + } + + // If no matching arm found, try default + if (schema.default) { + const value = this.readValue(schema.default); + return new XdrUnion(discriminant, value); + } + + throw new Error(`No matching union arm for discriminant: ${discriminant}`); + } +} diff --git a/src/xdr/__tests__/XdrDecoder.spec.ts b/src/xdr/__tests__/XdrDecoder.spec.ts new file mode 100644 index 00000000..18974191 --- /dev/null +++ b/src/xdr/__tests__/XdrDecoder.spec.ts @@ -0,0 +1,367 @@ +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {XdrEncoder} from '../XdrEncoder'; +import {XdrDecoder} from '../XdrDecoder'; + +describe('XdrDecoder', () => { + let reader: Reader; + let writer: Writer; + let encoder: XdrEncoder; + let decoder: XdrDecoder; + + beforeEach(() => { + reader = new Reader(); + writer = new Writer(); + encoder = new XdrEncoder(writer); + decoder = new XdrDecoder(reader); + }); + + describe('primitive types', () => { + test('decodes void', () => { + encoder.writeVoid(); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readVoid(); + expect(result).toBeUndefined(); + }); + + test('decodes boolean true', () => { + encoder.writeBoolean(true); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readBoolean(); + expect(result).toBe(true); + }); + + test('decodes boolean false', () => { + encoder.writeBoolean(false); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readBoolean(); + expect(result).toBe(false); + }); + + test('decodes positive int', () => { + const value = 42; + encoder.writeInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readInt(); + expect(result).toBe(value); + }); + + test('decodes negative int', () => { + const value = -1; + encoder.writeInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readInt(); + expect(result).toBe(value); + }); + + test('decodes large positive int', () => { + const value = 0x12345678; + encoder.writeInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readInt(); + expect(result).toBe(value); + }); + + test('decodes unsigned int', () => { + const value = 0xffffffff; + encoder.writeUnsignedInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readUnsignedInt(); + expect(result).toBe(value); + }); + + test('decodes hyper from bigint', () => { + const value = BigInt('0x123456789abcdef0'); + encoder.writeHyper(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readHyper(); + expect(result).toBe(value); + }); + + test('decodes negative hyper from bigint', () => { + const value = -BigInt('0x123456789abcdef0'); + encoder.writeHyper(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readHyper(); + expect(result).toBe(value); + }); + + test('decodes unsigned hyper from bigint', () => { + const value = BigInt('0xffffffffffffffff'); + encoder.writeUnsignedHyper(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readUnsignedHyper(); + expect(result).toBe(value); + }); + + test('decodes float', () => { + const value = 3.14; + encoder.writeFloat(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readFloat(); + expect(result).toBeCloseTo(value, 6); + }); + + test('decodes double', () => { + const value = Math.PI; + encoder.writeDouble(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readDouble(); + expect(result).toBeCloseTo(value, 15); + }); + + test('throws on quadruple', () => { + expect(() => decoder.readQuadruple()).toThrow('not implemented'); + }); + }); + + describe('opaque data', () => { + test('decodes fixed opaque data', () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + encoder.writeOpaque(data); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readOpaque(data.length); + expect(result).toEqual(data); + }); + + test('decodes fixed opaque data with padding', () => { + const data = new Uint8Array([1, 2, 3]); // 3 bytes -> 4 bytes with padding + encoder.writeOpaque(data); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readOpaque(data.length); + expect(result).toEqual(data); + }); + + test('decodes variable-length opaque data', () => { + const data = new Uint8Array([1, 2, 3, 4, 5]); + encoder.writeVarlenOpaque(data); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readVarlenOpaque(); + expect(result).toEqual(data); + }); + + test('decodes empty variable-length opaque data', () => { + const data = new Uint8Array([]); + encoder.writeVarlenOpaque(data); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readVarlenOpaque(); + expect(result).toEqual(data); + }); + }); + + describe('strings', () => { + test('decodes simple string', () => { + const value = 'hello'; + encoder.writeStr(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readString(); + expect(result).toBe(value); + }); + + test('decodes empty string', () => { + const value = ''; + encoder.writeStr(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readString(); + expect(result).toBe(value); + }); + + test('decodes UTF-8 string', () => { + const value = 'πŸš€ Hello, δΈ–η•Œ!'; + encoder.writeStr(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readString(); + expect(result).toBe(value); + }); + + test('decodes string that fits exactly in 4-byte boundary', () => { + const value = 'test'; // 4 bytes + encoder.writeStr(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readString(); + expect(result).toBe(value); + }); + }); + + describe('enum', () => { + test('decodes enum value', () => { + const value = 42; + encoder.writeInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readEnum(); + expect(result).toBe(value); + }); + }); + + describe('arrays', () => { + test('decodes fixed-size array', () => { + const values = [1, 2, 3]; + values.forEach((v) => encoder.writeInt(v)); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readArray(values.length, () => decoder.readInt()); + expect(result).toEqual(values); + }); + + test('decodes empty fixed-size array', () => { + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readArray(0, () => decoder.readInt()); + expect(result).toEqual([]); + }); + + test('decodes variable-length array', () => { + const values = [1, 2, 3, 4]; + encoder.writeUnsignedInt(values.length); + values.forEach((v) => encoder.writeInt(v)); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readVarlenArray(() => decoder.readInt()); + expect(result).toEqual(values); + }); + + test('decodes empty variable-length array', () => { + encoder.writeUnsignedInt(0); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readVarlenArray(() => decoder.readInt()); + expect(result).toEqual([]); + }); + }); + + describe('decode method', () => { + test('decode method calls readAny which throws', () => { + const encoded = new Uint8Array([0, 0, 0, 42]); + expect(() => decoder.decode(encoded)).toThrow('XdrDecoder.readAny() requires explicit type methods'); + }); + + test('read method calls readAny which throws', () => { + const encoded = new Uint8Array([0, 0, 0, 42]); + expect(() => decoder.read(encoded)).toThrow('XdrDecoder.readAny() requires explicit type methods'); + }); + }); + + describe('edge cases', () => { + test('handles 32-bit integer boundaries', () => { + const values = [-2147483648, 2147483647, 0]; + + for (const value of values) { + writer.reset(); + encoder.writeInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readInt(); + expect(result).toBe(value); + } + }); + + test('handles 32-bit unsigned integer boundaries', () => { + const values = [0, 4294967295]; + + for (const value of values) { + writer.reset(); + encoder.writeUnsignedInt(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readUnsignedInt(); + expect(result).toBe(value); + } + }); + + test('handles special float values', () => { + const values = [0, -0, Infinity, -Infinity]; + + for (const value of values) { + writer.reset(); + encoder.writeFloat(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readFloat(); + expect(result).toBe(value); + } + }); + + test('handles NaN float value', () => { + writer.reset(); + encoder.writeFloat(NaN); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readFloat(); + expect(result).toBeNaN(); + }); + + test('handles special double values', () => { + const values = [0, -0, Infinity, -Infinity]; + + for (const value of values) { + writer.reset(); + encoder.writeDouble(value); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readDouble(); + expect(result).toBe(value); + } + }); + + test('handles NaN double value', () => { + writer.reset(); + encoder.writeDouble(NaN); + const encoded = writer.flush(); + + reader.reset(encoded); + const result = decoder.readDouble(); + expect(result).toBeNaN(); + }); + }); +}); diff --git a/src/xdr/__tests__/XdrSchemaDecoder.spec.ts b/src/xdr/__tests__/XdrSchemaDecoder.spec.ts new file mode 100644 index 00000000..041f2a8c --- /dev/null +++ b/src/xdr/__tests__/XdrSchemaDecoder.spec.ts @@ -0,0 +1,474 @@ +import {Reader} from '@jsonjoy.com/buffers/lib/Reader'; +import {Writer} from '@jsonjoy.com/buffers/lib/Writer'; +import {XdrEncoder} from '../XdrEncoder'; +import {XdrSchemaEncoder} from '../XdrSchemaEncoder'; +import {XdrSchemaDecoder} from '../XdrSchemaDecoder'; +import {XdrUnion} from '../XdrUnion'; +import type {XdrSchema} from '../types'; + +describe('XdrSchemaDecoder', () => { + let reader: Reader; + let writer: Writer; + let encoder: XdrEncoder; + let schemaEncoder: XdrSchemaEncoder; + let decoder: XdrSchemaDecoder; + + beforeEach(() => { + reader = new Reader(); + writer = new Writer(); + encoder = new XdrEncoder(writer); + schemaEncoder = new XdrSchemaEncoder(writer); + decoder = new XdrSchemaDecoder(reader); + }); + + describe('primitive types with schema', () => { + test('decodes void with void schema', () => { + const schema: XdrSchema = {type: 'void'}; + const encoded = schemaEncoder.encode(null, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBeUndefined(); + }); + + test('decodes int with int schema', () => { + const schema: XdrSchema = {type: 'int'}; + const value = 42; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(value); + }); + + test('decodes unsigned int with unsigned_int schema', () => { + const schema: XdrSchema = {type: 'unsigned_int'}; + const value = 4294967295; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(value); + }); + + test('decodes boolean with boolean schema', () => { + const schema: XdrSchema = {type: 'boolean'}; + + let encoded = schemaEncoder.encode(true, schema); + let result = decoder.decode(encoded, schema); + expect(result).toBe(true); + + encoded = schemaEncoder.encode(false, schema); + result = decoder.decode(encoded, schema); + expect(result).toBe(false); + }); + + test('decodes hyper with hyper schema', () => { + const schema: XdrSchema = {type: 'hyper'}; + const value = BigInt('0x123456789abcdef0'); + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(value); + }); + + test('decodes unsigned hyper with unsigned_hyper schema', () => { + const schema: XdrSchema = {type: 'unsigned_hyper'}; + const value = BigInt('0xffffffffffffffff'); + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(value); + }); + + test('decodes float with float schema', () => { + const schema: XdrSchema = {type: 'float'}; + const value = 3.14; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBeCloseTo(value, 6); + }); + + test('decodes double with double schema', () => { + const schema: XdrSchema = {type: 'double'}; + const value = Math.PI; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBeCloseTo(value, 15); + }); + + test('throws on quadruple with quadruple schema', () => { + const schema: XdrSchema = {type: 'quadruple'}; + const value = 1.0; + + expect(() => schemaEncoder.encode(value, schema)).toThrow('not implemented'); + }); + }); + + describe('enum schemas', () => { + test('decodes valid enum value by name', () => { + const schema: XdrSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 2}, + }; + const encoded = schemaEncoder.encode('GREEN', schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe('GREEN'); + }); + + test('returns numeric value for unknown enum', () => { + const schema: XdrSchema = { + type: 'enum', + values: {RED: 0, GREEN: 1, BLUE: 2}, + }; + + // Manually encode a value that's not in the enum + encoder.writeInt(99); + const encoded = writer.flush(); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(99); + }); + }); + + describe('opaque schemas', () => { + test('decodes opaque data with correct size', () => { + const schema: XdrSchema = {type: 'opaque', size: 5}; + const value = new Uint8Array([1, 2, 3, 4, 5]); + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('decodes variable-length opaque data', () => { + const schema: XdrSchema = {type: 'vopaque'}; + const value = new Uint8Array([1, 2, 3, 4, 5]); + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('decodes variable-length opaque data with size limit', () => { + const schema: XdrSchema = {type: 'vopaque', size: 10}; + const value = new Uint8Array([1, 2, 3, 4, 5]); + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('throws on variable-length opaque data too large', () => { + const schema: XdrSchema = {type: 'vopaque', size: 3}; + + // Manually encode data larger than limit + const data = new Uint8Array([1, 2, 3, 4, 5]); + encoder.writeVarlenOpaque(data); + const encoded = writer.flush(); + + expect(() => decoder.decode(encoded, schema)).toThrow('exceeds maximum 3'); + }); + }); + + describe('string schemas', () => { + test('decodes string with string schema', () => { + const schema: XdrSchema = {type: 'string'}; + const value = 'Hello, XDR!'; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(value); + }); + + test('decodes string with size limit', () => { + const schema: XdrSchema = {type: 'string', size: 20}; + const value = 'Hello'; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toBe(value); + }); + + test('throws on string too long', () => { + const schema: XdrSchema = {type: 'string', size: 3}; + + // Manually encode a string longer than limit + const str = 'toolong'; + encoder.writeStr(str); + const encoded = writer.flush(); + + expect(() => decoder.decode(encoded, schema)).toThrow('exceeds maximum 3'); + }); + }); + + describe('array schemas', () => { + test('decodes fixed-size array', () => { + const schema: XdrSchema = { + type: 'array', + elements: {type: 'int'}, + size: 3, + }; + const value = [1, 2, 3]; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('decodes variable-length array', () => { + const schema: XdrSchema = { + type: 'varray', + elements: {type: 'int'}, + }; + const value = [1, 2, 3, 4]; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('decodes empty variable-length array', () => { + const schema: XdrSchema = { + type: 'varray', + elements: {type: 'int'}, + }; + const value: number[] = []; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('throws on variable-length array too large', () => { + const schema: XdrSchema = { + type: 'varray', + elements: {type: 'int'}, + size: 2, + }; + + // Manually encode array larger than limit + const values = [1, 2, 3]; + encoder.writeUnsignedInt(values.length); + values.forEach((v) => encoder.writeInt(v)); + const encoded = writer.flush(); + + expect(() => decoder.decode(encoded, schema)).toThrow('exceeds maximum 2'); + }); + + test('decodes nested arrays', () => { + const schema: XdrSchema = { + type: 'array', + elements: { + type: 'array', + elements: {type: 'int'}, + size: 2, + }, + size: 2, + }; + const value = [ + [1, 2], + [3, 4], + ]; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + }); + + describe('struct schemas', () => { + test('decodes simple struct', () => { + const schema: XdrSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [{type: 'string'}, 'name'], + ], + }; + const value = {id: 42, name: 'test'}; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('decodes nested struct', () => { + const schema: XdrSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'id'], + [ + { + type: 'struct', + fields: [ + [{type: 'string'}, 'first'], + [{type: 'string'}, 'last'], + ], + }, + 'name', + ], + ], + }; + const value = { + id: 42, + name: {first: 'John', last: 'Doe'}, + }; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + + test('decodes empty struct', () => { + const schema: XdrSchema = { + type: 'struct', + fields: [], + }; + const value = {}; + const encoded = schemaEncoder.encode(value, schema); + + const result = decoder.decode(encoded, schema); + expect(result).toEqual(value); + }); + }); + + describe('union schemas', () => { + test('decodes union value with numeric discriminant', () => { + const schema: XdrSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + + // Test first arm + let encoded = schemaEncoder.encode(new XdrUnion(0, 42), schema); + let result = decoder.decode(encoded, schema) as XdrUnion; + expect(result).toBeInstanceOf(XdrUnion); + expect(result.discriminant).toBe(0); + expect(result.value).toBe(42); + + // Test second arm + encoded = schemaEncoder.encode(new XdrUnion(1, 'hello'), schema); + result = decoder.decode(encoded, schema) as XdrUnion; + expect(result).toBeInstanceOf(XdrUnion); + expect(result.discriminant).toBe(1); + expect(result.value).toBe('hello'); + }); + + test('decodes union value with default', () => { + const schema: XdrSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + default: {type: 'boolean'}, + }; + + // Manually encode unknown discriminant + encoder.writeInt(99); // discriminant + encoder.writeBoolean(true); // default value + const encoded = writer.flush(); + + const result = decoder.decode(encoded, schema) as XdrUnion; + expect(result).toBeInstanceOf(XdrUnion); + expect(result.discriminant).toBe(99); + expect(result.value).toBe(true); + }); + + test('throws on union value with no matching arm', () => { + const schema: XdrSchema = { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }; + + // Manually encode unknown discriminant without default + encoder.writeInt(99); + encoder.writeInt(42); // some value + const encoded = writer.flush(); + + expect(() => decoder.decode(encoded, schema)).toThrow('No matching union arm for discriminant: 99'); + }); + }); + + describe('invalid schemas', () => { + test('throws on unknown schema type', () => { + const schema = {type: 'invalid'} as any; + const encoded = new Uint8Array([0, 0, 0, 42]); + + expect(() => decoder.decode(encoded, schema)).toThrow('Unknown schema type: invalid'); + }); + }); + + describe('complex nested schemas', () => { + test('decodes complex nested structure', () => { + const schema: XdrSchema = { + type: 'struct', + fields: [ + [{type: 'int'}, 'version'], + [ + { + type: 'varray', + elements: { + type: 'struct', + fields: [ + [{type: 'string'}, 'name'], + [{type: 'enum', values: {ACTIVE: 1, INACTIVE: 0}}, 'status'], + [ + { + type: 'union', + arms: [ + [0, {type: 'int'}], + [1, {type: 'string'}], + ], + }, + 'data', + ], + ], + }, + }, + 'items', + ], + ], + }; + + const value = { + version: 1, + items: [ + { + name: 'item1', + status: 'ACTIVE', + data: new XdrUnion(0, 42), + }, + { + name: 'item2', + status: 'INACTIVE', + data: new XdrUnion(1, 'test'), + }, + ], + }; + + const encoded = schemaEncoder.encode(value, schema); + const result = decoder.decode(encoded, schema) as any; + + expect(result.version).toBe(1); + expect(result.items).toHaveLength(2); + expect(result.items[0].name).toBe('item1'); + expect(result.items[0].status).toBe('ACTIVE'); + expect(result.items[0].data).toBeInstanceOf(XdrUnion); + expect(result.items[0].data.discriminant).toBe(0); + expect(result.items[0].data.value).toBe(42); + expect(result.items[1].name).toBe('item2'); + expect(result.items[1].status).toBe('INACTIVE'); + expect(result.items[1].data).toBeInstanceOf(XdrUnion); + expect(result.items[1].data.discriminant).toBe(1); + expect(result.items[1].data.value).toBe('test'); + }); + }); +}); diff --git a/src/xdr/index.ts b/src/xdr/index.ts index 46950b30..a4ce226e 100644 --- a/src/xdr/index.ts +++ b/src/xdr/index.ts @@ -7,6 +7,8 @@ export * from './types'; export * from './XdrEncoder'; +export * from './XdrDecoder'; export * from './XdrSchemaEncoder'; +export * from './XdrSchemaDecoder'; export * from './XdrSchemaValidator'; export * from './XdrUnion';