diff --git a/.changeset/core-parser-normalizer.md b/.changeset/core-parser-normalizer.md new file mode 100644 index 0000000..d74fdd4 --- /dev/null +++ b/.changeset/core-parser-normalizer.md @@ -0,0 +1,14 @@ +--- +'@ocpp-debugkit/core': minor +--- + +Implement trace parser, event normalizer, and Zod schemas. + +- `parseTrace()` accepts JSON Object, JSONL, and bare array trace formats +- `normalizeEvents()` classifies message types, infers directions (ADR-0004), + and normalizes timestamps to epoch milliseconds (ADR-0005) +- Zod schemas validate all untrusted input, preventing prototype pollution +- Input size limit (10 MB) and event count limit (10,000) enforced +- Malformed individual events are skipped with `ParseWarning` (ADR-0007) +- Added `Failure`, `Scenario`, `SessionSummary`, `ValidationResult` types +- 78 new unit tests (46 normalizer + 32 parser) diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 09f30c0..d1672e1 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,11 +8,10 @@ ## Active Milestone -**M0.5 — Protocol & Trace-Format Design Phase** +**v0.1.0 — Inspector MVP** -Design spike resolving all foundational design decisions before core -implementation. Produces ADRs, trace format specification, and synthetic -trace fixtures that validate the proposed internal event model. +Building the first usable release: trace → timeline → failures detected → +report exported. CLI and web inspector. ## What's Done @@ -20,7 +19,7 @@ trace fixtures that validate the proposed internal event model. - ✅ GitHub milestones created (M0, M0.5, v0.1.0, v0.2.0, v0.3.0, v1.0.0) - ✅ GitHub labels created (type, package, priority, workflow) -- ✅ Tracking issues created for M0 and M0.5 +- ✅ Tracking issues created for M0, M0.5, and v0.1.0 (#20–#32) ### Monorepo & Tooling (PR #12) @@ -55,37 +54,31 @@ trace fixtures that validate the proposed internal event model. - ✅ `README.md` (description, badges, architecture, quickstart, support, links) - ✅ `SECURITY.md` (vulnerability reporting, security principles) -### Protocol & Trace-Format Design (in progress — this PR) - -- ✅ 9 ADRs covering all design decisions: - - ADR-0001: OCPP version scope (1.6 JSON primary) - - ADR-0002: Input trace formats (JSON Object + JSONL) - - ADR-0003: Canonical internal event model - - ADR-0004: Message direction representation - - ADR-0005: Timestamp normalization - - ADR-0006: Session correlation strategy - - ADR-0007: Malformed trace handling - - ADR-0008: Browser-local processing & privacy - - ADR-0009: Future protocol-version extensibility +### Protocol & Trace-Format Design (PR #19) + +- ✅ 9 ADRs covering all design decisions - ✅ `docs/trace-format-spec.md` — full trace format specification -- ✅ 3 synthetic trace fixtures in `packages/core/src/__fixtures__/`: - - `normal-session.json` — complete charging session (no failures) - - `failed-auth.json` — failed authorization (expects `FAILED_AUTHORIZATION`) - - `connector-fault.json` — connector fault during session (expects `CONNECTOR_FAULT`) -- ✅ `packages/core/src/types.ts` — proposed canonical types (`Event`, `Trace`, `Session`, etc.) -- ✅ `packages/core/src/fixtures/index.ts` — fixture registry -- ✅ `packages/core/src/fixtures.test.ts` — 28 validation tests proving fixtures conform to the proposed event model +- ✅ 3 synthetic trace fixtures in `packages/core/src/__fixtures__/` +- ✅ Proposed canonical types and fixture validation tests + +### Core Package — Data Model + Parser + Normalizer (in progress — this PR) + +- ✅ `packages/core/src/schemas.ts` — Zod schemas for all input types +- ✅ `packages/core/src/normalizer.ts` — `normalizeEvents()`, direction inference (ADR-0004), timestamp normalization (ADR-0005) +- ✅ `packages/core/src/parser.ts` — `parseTrace()` accepting JSON Object, JSONL, bare array +- ✅ `packages/core/src/types.ts` — updated with `Failure`, `Scenario`, `SessionSummary`, `ValidationResult` types +- ✅ `packages/core/src/normalizer.test.ts` — 46 tests +- ✅ `packages/core/src/parser.test.ts` — 32 tests +- ✅ Untrusted-input handling: safe JSON parsing, size limits (10 MB), event count limits (10,000), prototype pollution protection, malformed event skip-and-flag ## What's Next -1. **M0.5 complete** → maintainer reviews and merges this PR -2. Proceed to v0.1.0 (Inspector MVP): - - Issue #13: Core data model + trace parser + event normalizer - - Issue #14: Core timeline + failure detection + summarizer + validator - - Issue #15: Core public API export + package config - - Issue #16: Scenarios package (format + 5 initial scenarios) - - Issue #17: Reporter package (Markdown report generator) - - Issue #18: CLI package (scaffold + inspect + report + scenario commands) +1. **Issue #20** (this PR) → complete: data model + parser + normalizer +2. **Issue #21**: Core timeline + failure detection + summarizer + validator +3. **Issue #22**: Core public API export + package config +4. **Issue #23**: Scenarios package (format + 5 initial scenarios) +5. **Issue #24**: Reporter package (Markdown report generator) +6. **Issue #25**: CLI package (scaffold + inspect + report + scenario commands) ## Known Blockers / Decisions Pending @@ -95,7 +88,7 @@ trace fixtures that validate the proposed internal event model. | Package | Status | Version | |---------|--------|---------| -| `@ocpp-debugkit/core` | in progress (types + fixtures) | 0.0.0 | +| `@ocpp-debugkit/core` | in progress (parser + normalizer done) | 0.0.0 | | `@ocpp-debugkit/scenarios` | not started | — | | `@ocpp-debugkit/reporter` | not started | — | | `@ocpp-debugkit/cli` | not started | — | diff --git a/packages/core/package.json b/packages/core/package.json index e4a13a9..14b2ece 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -23,7 +23,9 @@ "test": "vitest run", "clean": "rm -rf dist .turbo" }, - "dependencies": {}, + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "typescript": "^5.7.0", "vitest": "^3.0.0" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a433fd0..5ce8699 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,5 +2,28 @@ * Barrel export for the @ocpp-debugkit/core package. */ +// Types export * from './types.js'; + +// Schemas (Zod) +export * from './schemas.js'; + +// Parser +export { parseTrace, ParseError, MAX_INPUT_SIZE_BYTES, MAX_EVENT_COUNT } from './parser.js'; +export type { TraceFormat } from './parser.js'; + +// Normalizer +export { + normalizeEvents, + normalizeTimestamp, + inferDirection, + reverseDirection, + classifyMessageType, + extractAction, + extractPayload, + extractErrorCode, + extractErrorDescription, +} from './normalizer.js'; + +// Fixtures export * from './fixtures/index.js'; diff --git a/packages/core/src/normalizer.test.ts b/packages/core/src/normalizer.test.ts new file mode 100644 index 0000000..6679e5e --- /dev/null +++ b/packages/core/src/normalizer.test.ts @@ -0,0 +1,330 @@ +import { describe, it, expect } from 'vitest'; +import { + normalizeEvents, + normalizeTimestamp, + inferDirection, + reverseDirection, + classifyMessageType, + extractAction, + extractPayload, + extractErrorCode, + extractErrorDescription, +} from './normalizer.js'; +import type { TraceEventInput, RawOcppMessage, Direction } from './types.js'; + +// Helpers +const call = (id: string, action: string, payload: Record = {}) => + [2, id, action, payload] as RawOcppMessage; +const callResult = (id: string, payload: Record = {}) => + [3, id, payload] as RawOcppMessage; +const callError = (id: string, code = 'InternalError', desc = 'desc', details = {}) => + [4, id, code, desc, details] as RawOcppMessage; + +// --------------------------------------------------------------------------- +// normalizeTimestamp +// --------------------------------------------------------------------------- + +describe('normalizeTimestamp', () => { + it('parses ISO 8601 UTC string', () => { + expect(normalizeTimestamp('2024-01-15T10:00:00.000Z')).toBe( + Date.parse('2024-01-15T10:00:00.000Z'), + ); + }); + + it('parses ISO 8601 with offset', () => { + const ts = normalizeTimestamp('2024-01-15T12:00:00+02:00'); + expect(ts).toBe(Date.parse('2024-01-15T12:00:00+02:00')); + }); + + it('parses Unix epoch seconds (below 10^12)', () => { + expect(normalizeTimestamp(1705312200)).toBe(1705312200000); + }); + + it('parses Unix epoch milliseconds (above 10^12)', () => { + expect(normalizeTimestamp(1705312200000)).toBe(1705312200000); + }); + + it('returns null for null', () => { + expect(normalizeTimestamp(null)).toBeNull(); + }); + + it('returns null for undefined', () => { + expect(normalizeTimestamp(undefined)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(normalizeTimestamp('')).toBeNull(); + }); + + it('returns null for invalid string', () => { + expect(normalizeTimestamp('not a date')).toBeNull(); + }); + + it('parses stringified epoch seconds', () => { + expect(normalizeTimestamp('1705312200')).toBe(1705312200000); + }); + + it('parses stringified epoch milliseconds', () => { + expect(normalizeTimestamp('1705312200000')).toBe(1705312200000); + }); + + it('returns null for NaN', () => { + expect(normalizeTimestamp(NaN)).toBeNull(); + }); + + it('returns null for Infinity', () => { + expect(normalizeTimestamp(Infinity)).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// inferDirection +// --------------------------------------------------------------------------- + +describe('inferDirection', () => { + it('returns CS_TO_CSMS for BootNotification', () => { + expect(inferDirection('Call', 'BootNotification')).toBe('CS_TO_CSMS'); + }); + + it('returns CS_TO_CSMS for StartTransaction', () => { + expect(inferDirection('Call', 'StartTransaction')).toBe('CS_TO_CSMS'); + }); + + it('returns CSMS_TO_CS for Reset', () => { + expect(inferDirection('Call', 'Reset')).toBe('CSMS_TO_CS'); + }); + + it('returns CSMS_TO_CS for RemoteStartTransaction', () => { + expect(inferDirection('Call', 'RemoteStartTransaction')).toBe('CSMS_TO_CS'); + }); + + it('returns UNKNOWN for unrecognized action', () => { + expect(inferDirection('Call', 'UnknownAction')).toBe('UNKNOWN'); + }); + + it('returns UNKNOWN for CallResult (no action)', () => { + expect(inferDirection('CallResult', null)).toBe('UNKNOWN'); + }); +}); + +// --------------------------------------------------------------------------- +// reverseDirection +// --------------------------------------------------------------------------- + +describe('reverseDirection', () => { + it('reverses CS_TO_CSMS to CSMS_TO_CS', () => { + expect(reverseDirection('CS_TO_CSMS')).toBe('CSMS_TO_CS'); + }); + + it('reverses CSMS_TO_CS to CS_TO_CSMS', () => { + expect(reverseDirection('CSMS_TO_CS')).toBe('CS_TO_CSMS'); + }); + + it('keeps UNKNOWN as UNKNOWN', () => { + expect(reverseDirection('UNKNOWN')).toBe('UNKNOWN'); + }); +}); + +// --------------------------------------------------------------------------- +// classifyMessageType +// --------------------------------------------------------------------------- + +describe('classifyMessageType', () => { + it('classifies 2 as Call', () => { + expect(classifyMessageType(call('m1', 'BootNotification'))).toBe('Call'); + }); + + it('classifies 3 as CallResult', () => { + expect(classifyMessageType(callResult('m1'))).toBe('CallResult'); + }); + + it('classifies 4 as CallError', () => { + expect(classifyMessageType(callError('m1'))).toBe('CallError'); + }); +}); + +// --------------------------------------------------------------------------- +// extractAction / extractPayload / extractError* +// --------------------------------------------------------------------------- + +describe('extractAction', () => { + it('extracts action from Call', () => { + expect(extractAction(call('m1', 'BootNotification'))).toBe('BootNotification'); + }); + + it('returns null for CallResult', () => { + expect(extractAction(callResult('m1'))).toBeNull(); + }); + + it('returns null for CallError', () => { + expect(extractAction(callError('m1'))).toBeNull(); + }); +}); + +describe('extractPayload', () => { + it('extracts payload from Call (index 3)', () => { + const payload = { vendor: 'Test' }; + expect(extractAction(call('m1', 'BootNotification', payload))).toBe('BootNotification'); + expect(extractPayload(call('m1', 'BootNotification', payload))).toEqual(payload); + }); + + it('extracts payload from CallResult (index 2)', () => { + const payload = { status: 'Accepted' }; + expect(extractPayload(callResult('m1', payload))).toEqual(payload); + }); + + it('extracts ErrorDetails from CallError (index 4)', () => { + const details = { extra: 'info' }; + expect(extractPayload(callError('m1', 'Code', 'Desc', details))).toEqual(details); + }); +}); + +describe('extractErrorCode', () => { + it('extracts error code from CallError', () => { + expect(extractErrorCode(callError('m1', 'SecurityError', 'desc'))).toBe('SecurityError'); + }); + + it('returns null for Call', () => { + expect(extractErrorCode(call('m1', 'BootNotification'))).toBeNull(); + }); + + it('returns null for CallResult', () => { + expect(extractErrorCode(callResult('m1'))).toBeNull(); + }); +}); + +describe('extractErrorDescription', () => { + it('extracts error description from CallError', () => { + expect(extractErrorDescription(callError('m1', 'Code', 'Bad things happened'))).toBe( + 'Bad things happened', + ); + }); + + it('returns null for Call', () => { + expect(extractErrorDescription(call('m1', 'BootNotification'))).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// normalizeEvents +// --------------------------------------------------------------------------- + +describe('normalizeEvents', () => { + it('normalizes a simple Call + CallResult pair', () => { + const inputs: TraceEventInput[] = [ + { + timestamp: '2024-01-15T10:00:00.000Z', + direction: 'CS_TO_CSMS', + message: call('m1', 'BootNotification'), + }, + { + timestamp: '2024-01-15T10:00:00.500Z', + direction: 'CSMS_TO_CS', + message: callResult('m1', { status: 'Accepted' }), + }, + ]; + + const events = normalizeEvents(inputs); + expect(events).toHaveLength(2); + expect(events[0]?.messageType).toBe('Call'); + expect(events[0]?.action).toBe('BootNotification'); + expect(events[0]?.direction).toBe('CS_TO_CSMS'); + expect(events[1]?.messageType).toBe('CallResult'); + expect(events[1]?.action).toBeNull(); + expect(events[1]?.direction).toBe('CSMS_TO_CS'); + }); + + it('generates sequential IDs', () => { + const inputs: TraceEventInput[] = [ + { message: call('m1', 'BootNotification') }, + { message: callResult('m1') }, + { message: call('m2', 'Heartbeat') }, + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.id).toBe('evt-0001'); + expect(events[1]?.id).toBe('evt-0002'); + expect(events[2]?.id).toBe('evt-0003'); + }); + + it('infers direction from action when not provided', () => { + const inputs: TraceEventInput[] = [ + { message: call('m1', 'BootNotification') }, // no direction + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.direction).toBe('CS_TO_CSMS'); + }); + + it('infers response direction from matched Call', () => { + const inputs: TraceEventInput[] = [ + { message: call('m1', 'BootNotification') }, // no direction — inferred CS_TO_CSMS + { message: callResult('m1') }, // no direction — inferred from Call + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.direction).toBe('CS_TO_CSMS'); + expect(events[1]?.direction).toBe('CSMS_TO_CS'); + }); + + it('keeps UNKNOWN for unmatched responses', () => { + const inputs: TraceEventInput[] = [ + { message: callResult('m1') }, // no matching Call + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.direction).toBe('UNKNOWN'); + }); + + it('normalizes timestamps', () => { + const inputs: TraceEventInput[] = [ + { timestamp: '2024-01-15T10:00:00.000Z', message: call('m1', 'BootNotification') }, + { timestamp: 1705312200, message: call('m2', 'Heartbeat') }, // seconds + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.timestamp).toBe(Date.parse('2024-01-15T10:00:00.000Z')); + expect(events[1]?.timestamp).toBe(1705312200000); + }); + + it('handles missing timestamps', () => { + const inputs: TraceEventInput[] = [{ message: call('m1', 'BootNotification') }]; + + const events = normalizeEvents(inputs); + expect(events[0]?.timestamp).toBeNull(); + }); + + it('preserves rawMessage', () => { + const rawMsg = call('m1', 'BootNotification', { vendor: 'Test' }); + const inputs: TraceEventInput[] = [{ message: rawMsg }]; + + const events = normalizeEvents(inputs); + expect(events[0]?.rawMessage).toEqual(rawMsg); + }); + + it('extracts error fields from CallError', () => { + const inputs: TraceEventInput[] = [ + { message: callError('m1', 'SecurityError', 'Cert invalid', { detail: true }) }, + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.messageType).toBe('CallError'); + expect(events[0]?.errorCode).toBe('SecurityError'); + expect(events[0]?.errorDescription).toBe('Cert invalid'); + expect(events[0]?.payload).toEqual({ detail: true }); + }); + + it('respects explicit direction over inferred', () => { + const inputs: TraceEventInput[] = [ + { direction: 'UNKNOWN' as Direction, message: call('m1', 'BootNotification') }, + ]; + + const events = normalizeEvents(inputs); + expect(events[0]?.direction).toBe('UNKNOWN'); + }); + + it('handles empty array', () => { + const events = normalizeEvents([]); + expect(events).toHaveLength(0); + }); +}); diff --git a/packages/core/src/normalizer.ts b/packages/core/src/normalizer.ts new file mode 100644 index 0000000..7ba174c --- /dev/null +++ b/packages/core/src/normalizer.ts @@ -0,0 +1,312 @@ +/** + * Event normalizer — transforms raw trace event inputs into canonical + * `Event` objects with classified message type, direction, and timestamps. + * + * @see ADR-0003 (canonical event model) + * @see ADR-0004 (message direction) + * @see ADR-0005 (timestamp normalization) + */ + +import type { Direction, Event, MessageType, TraceEventInput, RawOcppMessage } from './types.js'; + +// --------------------------------------------------------------------------- +// Direction inference (ADR-0004) +// --------------------------------------------------------------------------- + +/** + * Actions initiated by the Charge Point (CS → CSMS). + * These are OCPP 1.6 actions sent FROM the station TO the CSMS. + */ +const CS_TO_CSMS_ACTIONS = new Set([ + 'BootNotification', + 'Heartbeat', + 'Authorize', + 'StartTransaction', + 'StopTransaction', + 'StatusNotification', + 'MeterValues', + 'DataTransfer', + 'DiagnosticsStatusNotification', + 'FirmwareStatusNotification', + 'SecurityEventNotification', + 'SignCertificate', + 'SignedFirmwareStatusNotification', + 'LogStatusNotification', +]); + +/** + * Actions initiated by the CSMS (CSMS → CS). + * These are OCPP 1.6 actions sent FROM the CSMS TO the station. + */ +const CSMS_TO_CS_ACTIONS = new Set([ + 'Reset', + 'RemoteStartTransaction', + 'RemoteStopTransaction', + 'GetConfiguration', + 'ChangeConfiguration', + 'SetChargingProfile', + 'ClearChargingProfile', + 'ChangeAvailability', + 'ReserveNow', + 'CancelReservation', + 'DataTransfer', + 'GetLocalListVersion', + 'SendLocalList', + 'TriggerMessage', + 'UnlockConnector', + 'GetDiagnostics', + 'UpdateFirmware', + 'ExtendedTriggerMessage', + 'GetLog', + 'SignedUpdateFirmware', + 'CertificateSigned', + 'DeleteCertificate', + 'GetInstalledCertificateIds', + 'InstallCertificate', +]); + +/** + * Infer the direction of a Call message from its action name (ADR-0004). + * - If the action is known to be CS→CSMS, return 'CS_TO_CSMS'. + * - If the action is known to be CSMS→CS, return 'CSMS_TO_CS'. + * - If the action is not recognized, return 'UNKNOWN'. + * + * For CallResult/CallError, the direction is the reverse of the original Call. + * Since we don't have the original Call's direction here, we infer based on + * the matched Call's action. For responses without a matched Call, we use + * 'UNKNOWN'. + */ +export function inferDirection(messageType: MessageType, action: string | null): Direction { + if (messageType === 'Call' && action !== null) { + if (CS_TO_CSMS_ACTIONS.has(action)) return 'CS_TO_CSMS'; + if (CSMS_TO_CS_ACTIONS.has(action)) return 'CSMS_TO_CS'; + return 'UNKNOWN'; + } + // For CallResult/CallError, direction should be the reverse of the Call. + // We can't know for certain without matching — return UNKNOWN. + // The parser will resolve this after matching Calls to responses. + return 'UNKNOWN'; +} + +/** + * Determine the reverse direction for a response. + * Call CS→CSMS → Response CSMS→CS, and vice versa. + */ +export function reverseDirection(dir: Direction): Direction { + switch (dir) { + case 'CS_TO_CSMS': + return 'CSMS_TO_CS'; + case 'CSMS_TO_CS': + return 'CS_TO_CSMS'; + case 'UNKNOWN': + return 'UNKNOWN'; + } +} + +// --------------------------------------------------------------------------- +// Timestamp normalization (ADR-0005) +// --------------------------------------------------------------------------- + +/** + * Threshold for distinguishing Unix epoch seconds from milliseconds. + * Values below 10^12 are treated as seconds; above as milliseconds. + * (10^12 = year ~33658 in ms, year ~2001 in s) + */ +const EPOCH_MS_THRESHOLD = 1e12; + +/** + * Normalize a timestamp to epoch milliseconds. + * + * Accepts: + * - ISO 8601 strings (UTC or with offset) + * - Unix epoch in milliseconds (number >= 10^12) + * - Unix epoch in seconds (number < 10^12) + * - null/undefined → null + * + * @returns epoch milliseconds, or null if the timestamp is missing or invalid. + */ +export function normalizeTimestamp(timestamp: string | number | null | undefined): number | null { + if (timestamp === null || timestamp === undefined) { + return null; + } + + if (typeof timestamp === 'number') { + if (!Number.isFinite(timestamp)) { + return null; + } + // Detect seconds vs milliseconds + return timestamp < EPOCH_MS_THRESHOLD ? Math.round(timestamp * 1000) : Math.round(timestamp); + } + + if (typeof timestamp === 'string') { + const trimmed = timestamp.trim(); + if (trimmed === '') { + return null; + } + // Try parsing as a number first (stringified epoch) + const asNum = Number(trimmed); + if (Number.isFinite(asNum)) { + return asNum < EPOCH_MS_THRESHOLD ? Math.round(asNum * 1000) : Math.round(asNum); + } + // Parse as ISO 8601 + const parsed = Date.parse(trimmed); + if (Number.isNaN(parsed)) { + return null; + } + return parsed; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Message type classification +// --------------------------------------------------------------------------- + +/** + * Classify the OCPP message type from the raw message array. + * - [2, ...] → 'Call' + * - [3, ...] → 'CallResult' + * - [4, ...] → 'CallError' + */ +export function classifyMessageType(message: RawOcppMessage): MessageType { + const typeId = message[0]; + switch (typeId) { + case 2: + return 'Call'; + case 3: + return 'CallResult'; + case 4: + return 'CallError'; + default: + // Should not happen — schema validation prevents this + return 'Call'; + } +} + +/** + * Extract the action name from a Call message. + * Only Call messages have an action (at index 2). + */ +export function extractAction(message: RawOcppMessage): string | null { + if (message[0] === 2 && message.length >= 3) { + const action = message[2]; + return typeof action === 'string' ? action : null; + } + return null; +} + +/** + * Extract the payload from any message type. + * - Call: index 3 + * - CallResult: index 2 + * - CallError: index 4 (ErrorDetails) + */ +export function extractPayload(message: RawOcppMessage): unknown { + switch (message[0]) { + case 2: + return message[3] ?? null; + case 3: + return message[2] ?? null; + case 4: + return message[4] ?? null; + default: + return null; + } +} + +/** + * Extract error code from a CallError message (index 2). + */ +export function extractErrorCode(message: RawOcppMessage): string | null { + if (message[0] === 4 && message.length >= 3) { + const code = message[2]; + return typeof code === 'string' ? code : null; + } + return null; +} + +/** + * Extract error description from a CallError message (index 3). + */ +export function extractErrorDescription(message: RawOcppMessage): string | null { + if (message[0] === 4 && message.length >= 4) { + const desc = message[3]; + return typeof desc === 'string' ? desc : null; + } + return null; +} + +// --------------------------------------------------------------------------- +// normalizeEvents() +// --------------------------------------------------------------------------- + +/** + * Normalize an array of raw trace event inputs into canonical `Event` objects. + * + * This function: + * 1. Classifies the message type (Call/CallResult/CallError) + * 2. Extracts action, payload, error fields + * 3. Normalizes timestamps to epoch milliseconds + * 4. Resolves direction: uses explicit direction if provided, otherwise + * infers from action name (for Calls) or from the matched Call (for responses) + * 5. Generates sequential event IDs + * + * Events are NOT reordered (ADR-0005). Out-of-order timestamps are preserved + * as-is; the caller can detect them after normalization. + * + * @see ADR-0003, ADR-0004, ADR-0005 + */ +export function normalizeEvents(inputs: TraceEventInput[]): Event[] { + // First pass: create events with best-effort direction + const events: Event[] = inputs.map((input, index) => { + const message = input.message; + const messageType = classifyMessageType(message); + const action = extractAction(message); + + // Use explicit direction, or infer from action for Call messages. + // An explicit direction (including 'UNKNOWN') is respected as-is. + // Only infer when direction is not provided at all (undefined). + let direction = input.direction; + if (direction === undefined && messageType === 'Call' && action !== null) { + direction = inferDirection(messageType, action); + } + if (direction === undefined) { + direction = 'UNKNOWN'; + } + + return { + id: `evt-${String(index + 1).padStart(4, '0')}`, + messageId: message[1], + timestamp: normalizeTimestamp(input.timestamp), + direction, + messageType, + action, + payload: extractPayload(message), + errorCode: extractErrorCode(message), + errorDescription: extractErrorDescription(message), + rawMessage: message, + }; + }); + + // Second pass: resolve response directions by matching to their original Calls. + // Build a map of messageId → Call direction. + const callDirections = new Map(); + for (const event of events) { + if (event.messageType === 'Call' && event.direction !== 'UNKNOWN') { + callDirections.set(event.messageId, event.direction); + } + } + + // For CallResult/CallError with UNKNOWN direction, infer from the matched Call + for (const event of events) { + if (event.direction === 'UNKNOWN' && event.messageType !== 'Call') { + const callDir = callDirections.get(event.messageId); + if (callDir) { + event.direction = reverseDirection(callDir); + } + } + } + + return events; +} diff --git a/packages/core/src/parser.test.ts b/packages/core/src/parser.test.ts new file mode 100644 index 0000000..5704892 --- /dev/null +++ b/packages/core/src/parser.test.ts @@ -0,0 +1,387 @@ +import { describe, it, expect } from 'vitest'; +import { parseTrace, ParseError, MAX_INPUT_SIZE_BYTES, MAX_EVENT_COUNT } from './parser.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A minimal valid Call message. */ +const call = (id: string, action: string, payload: Record = {}) => + [2, id, action, payload] as [number, string, ...unknown[]]; + +/** A minimal valid CallResult message. */ +const callResult = (id: string, payload: Record = {}) => + [3, id, payload] as [number, string, ...unknown[]]; + +/** A minimal valid CallError message. */ +const callError = (id: string, code = 'InternalError', desc = 'desc', details = {}) => + [4, id, code, desc, details] as [number, string, ...unknown[]]; + +// --------------------------------------------------------------------------- +// JSON Object format +// --------------------------------------------------------------------------- + +describe('parseTrace — JSON Object format', () => { + it('parses a valid JSON Object trace', () => { + const input = JSON.stringify({ + traceId: 'test-001', + metadata: { stationId: 'CS-001', ocppVersion: '1.6' }, + events: [ + { + timestamp: '2024-01-15T10:00:00.000Z', + direction: 'CS_TO_CSMS', + message: call('m1', 'BootNotification'), + }, + { + timestamp: '2024-01-15T10:00:00.500Z', + direction: 'CSMS_TO_CS', + message: callResult('m1', { status: 'Accepted' }), + }, + ], + }); + + const result = parseTrace(input); + expect(result.events).toHaveLength(2); + expect(result.warnings).toHaveLength(0); + expect(result.events[0]?.messageType).toBe('Call'); + expect(result.events[0]?.action).toBe('BootNotification'); + expect(result.events[1]?.messageType).toBe('CallResult'); + }); + + it('parses a trace without metadata', () => { + const input = JSON.stringify({ + events: [{ message: call('m1', 'BootNotification') }, { message: callResult('m1') }], + }); + + const result = parseTrace(input); + expect(result.events).toHaveLength(2); + }); + + it('throws on empty events array', () => { + const input = JSON.stringify({ events: [] }); + expect(() => parseTrace(input)).toThrow(ParseError); + }); + + it('throws on missing events field', () => { + const input = JSON.stringify({ traceId: 'test' }); + expect(() => parseTrace(input)).toThrow(ParseError); + }); + + it('throws on invalid JSON', () => { + expect(() => parseTrace('{ invalid json }')).toThrow(ParseError); + }); + + it('throws on empty input', () => { + expect(() => parseTrace('')).toThrow(ParseError); + expect(() => parseTrace(' ')).toThrow(ParseError); + }); +}); + +// --------------------------------------------------------------------------- +// JSONL format +// --------------------------------------------------------------------------- + +describe('parseTrace — JSONL format', () => { + it('parses valid JSONL', () => { + const input = [ + JSON.stringify({ + timestamp: '2024-01-15T10:00:00.000Z', + direction: 'CS_TO_CSMS', + message: call('m1', 'BootNotification'), + }), + JSON.stringify({ + timestamp: '2024-01-15T10:00:00.500Z', + direction: 'CSMS_TO_CS', + message: callResult('m1', { status: 'Accepted' }), + }), + ].join('\n'); + + const result = parseTrace(input); + expect(result.events).toHaveLength(2); + expect(result.warnings).toHaveLength(0); + }); + + it('skips blank lines', () => { + const input = [ + JSON.stringify({ message: call('m1', 'BootNotification') }), + '', + ' ', + JSON.stringify({ message: callResult('m1') }), + ].join('\n'); + + const result = parseTrace(input); + expect(result.events).toHaveLength(2); + }); + + it('produces warnings for malformed lines', () => { + const input = [ + JSON.stringify({ message: call('m1', 'BootNotification') }), + '{ bad json', + JSON.stringify({ message: callResult('m1') }), + ].join('\n'); + + const result = parseTrace(input); + expect(result.events).toHaveLength(2); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]?.index).toBe(1); + }); + + it('produces warnings for invalid event structure', () => { + const input = [ + JSON.stringify({ message: call('m1', 'BootNotification') }), + JSON.stringify({ foo: 'bar' }), // missing message field + ].join('\n'); + + const result = parseTrace(input); + expect(result.events).toHaveLength(1); + expect(result.warnings).toHaveLength(1); + }); +}); + +// --------------------------------------------------------------------------- +// Bare array format +// --------------------------------------------------------------------------- + +describe('parseTrace — bare array format', () => { + it('parses a bare array of raw OCPP messages', () => { + const input = JSON.stringify([ + call('m1', 'BootNotification', { chargePointVendor: 'Test' }), + callResult('m1', { status: 'Accepted' }), + ]); + + const result = parseTrace(input); + expect(result.events).toHaveLength(2); + expect(result.events[0]?.messageType).toBe('Call'); + expect(result.events[0]?.action).toBe('BootNotification'); + expect(result.events[0]?.timestamp).toBeNull(); + }); + + it('throws on empty bare array', () => { + const input = '[]'; + expect(() => parseTrace(input)).toThrow(ParseError); + }); +}); + +// --------------------------------------------------------------------------- +// Normalization +// --------------------------------------------------------------------------- + +describe('parseTrace — normalization', () => { + it('normalizes ISO 8601 timestamps to epoch ms', () => { + const input = JSON.stringify({ + events: [{ timestamp: '2024-01-15T10:00:00.000Z', message: call('m1', 'BootNotification') }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.timestamp).toBe(Date.parse('2024-01-15T10:00:00.000Z')); + }); + + it('normalizes Unix epoch seconds to ms', () => { + const input = JSON.stringify({ + events: [ + { timestamp: 1705312200, message: call('m1', 'BootNotification') }, // seconds + ], + }); + + const result = parseTrace(input); + expect(result.events[0]?.timestamp).toBe(1705312200000); + }); + + it('normalizes Unix epoch milliseconds directly', () => { + const input = JSON.stringify({ + events: [{ timestamp: 1705312200000, message: call('m1', 'BootNotification') }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.timestamp).toBe(1705312200000); + }); + + it('sets timestamp to null when missing', () => { + const input = JSON.stringify({ + events: [{ message: call('m1', 'BootNotification') }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.timestamp).toBeNull(); + }); + + it('sets timestamp to null for invalid string', () => { + const input = JSON.stringify({ + events: [{ timestamp: 'not a date', message: call('m1', 'BootNotification') }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.timestamp).toBeNull(); + }); + + it('infers direction from action name for Call messages', () => { + const input = JSON.stringify({ + events: [ + { message: [2, 'm1', 'BootNotification', {}] }, // no direction + ], + }); + + const result = parseTrace(input); + expect(result.events[0]?.direction).toBe('CS_TO_CSMS'); + }); + + it('infers CSMS_TO_CS for CSMS-initiated actions', () => { + const input = JSON.stringify({ + events: [ + { message: [2, 'm1', 'Reset', {}] }, // no direction + ], + }); + + const result = parseTrace(input); + expect(result.events[0]?.direction).toBe('CSMS_TO_CS'); + }); + + it('resolves response direction from matched Call', () => { + const input = JSON.stringify({ + events: [ + { message: [2, 'm1', 'BootNotification', {}] }, // CS_TO_CSMS (inferred) + { message: [3, 'm1', { status: 'Accepted' }] }, // no direction — should be CSMS_TO_CS + ], + }); + + const result = parseTrace(input); + expect(result.events[0]?.direction).toBe('CS_TO_CSMS'); + expect(result.events[1]?.direction).toBe('CSMS_TO_CS'); + }); + + it('generates sequential event IDs', () => { + const input = JSON.stringify({ + events: [ + { message: call('m1', 'BootNotification') }, + { message: callResult('m1') }, + { message: call('m2', 'Heartbeat') }, + ], + }); + + const result = parseTrace(input); + expect(result.events[0]?.id).toBe('evt-0001'); + expect(result.events[1]?.id).toBe('evt-0002'); + expect(result.events[2]?.id).toBe('evt-0003'); + }); + + it('extracts action from Call messages', () => { + const input = JSON.stringify({ + events: [{ message: call('m1', 'StartTransaction') }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.action).toBe('StartTransaction'); + }); + + it('sets action to null for CallResult', () => { + const input = JSON.stringify({ + events: [{ message: callResult('m1', { status: 'Accepted' }) }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.action).toBeNull(); + }); + + it('extracts error fields from CallError', () => { + const input = JSON.stringify({ + events: [{ message: callError('m1', 'SecurityError', 'Certificate invalid', {}) }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.messageType).toBe('CallError'); + expect(result.events[0]?.errorCode).toBe('SecurityError'); + expect(result.events[0]?.errorDescription).toBe('Certificate invalid'); + }); + + it('preserves rawMessage', () => { + const rawMsg = call('m1', 'BootNotification', { vendor: 'Test' }); + const input = JSON.stringify({ + events: [{ message: rawMsg }], + }); + + const result = parseTrace(input); + expect(result.events[0]?.rawMessage).toEqual(rawMsg); + }); +}); + +// --------------------------------------------------------------------------- +// Limits and security +// --------------------------------------------------------------------------- + +describe('parseTrace — limits and security', () => { + it('throws on input exceeding size limit', () => { + // Create a string that exceeds MAX_INPUT_SIZE_BYTES + const huge = 'x'.repeat(MAX_INPUT_SIZE_BYTES + 1); + expect(() => parseTrace(huge)).toThrow(ParseError); + expect(() => parseTrace(huge)).toThrow(/exceeds the maximum allowed size/); + }); + + it('throws on event count exceeding limit', () => { + // Create a trace with MAX_EVENT_COUNT + 1 events + const events = Array.from({ length: MAX_EVENT_COUNT + 1 }, (_, i) => ({ + message: call(`m${i}`, 'BootNotification'), + })); + const input = JSON.stringify({ events }); + + expect(() => parseTrace(input)).toThrow(ParseError); + expect(() => parseTrace(input)).toThrow(/exceeds the maximum allowed count/); + }); + + it('handles prototype pollution attempts safely', () => { + const input = JSON.stringify({ + events: [{ message: call('m1', 'BootNotification') }], + __proto__: { polluted: true }, + }); + + const result = parseTrace(input); + expect(result.events).toHaveLength(1); + // The prototype should not be polluted + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('does not reorder out-of-order events', () => { + const input = JSON.stringify({ + events: [ + { timestamp: '2024-01-15T10:01:00.000Z', message: call('m2', 'Heartbeat') }, + { timestamp: '2024-01-15T10:00:00.000Z', message: call('m1', 'BootNotification') }, + ], + }); + + const result = parseTrace(input); + // Events should stay in original order (ADR-0005) + expect(result.events[0]?.messageId).toBe('m2'); + expect(result.events[1]?.messageId).toBe('m1'); + // But timestamps should be normalized correctly + expect(result.events[0]?.timestamp).toBeGreaterThan(result.events[1]?.timestamp ?? 0); + }); +}); + +// --------------------------------------------------------------------------- +// Existing fixtures +// --------------------------------------------------------------------------- + +describe('parseTrace — existing fixtures', () => { + it('parses normal-session fixture', async () => { + const { normalSession } = await import('./fixtures/index.js'); + const input = JSON.stringify(normalSession); + const result = parseTrace(input); + expect(result.events.length).toBe(normalSession.events.length); + expect(result.warnings).toHaveLength(0); + }); + + it('parses failed-auth fixture', async () => { + const { failedAuth } = await import('./fixtures/index.js'); + const input = JSON.stringify(failedAuth); + const result = parseTrace(input); + expect(result.events.length).toBe(failedAuth.events.length); + expect(result.warnings).toHaveLength(0); + }); + + it('parses connector-fault fixture', async () => { + const { connectorFault } = await import('./fixtures/index.js'); + const input = JSON.stringify(connectorFault); + const result = parseTrace(input); + expect(result.events.length).toBe(connectorFault.events.length); + expect(result.warnings).toHaveLength(0); + }); +}); diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts new file mode 100644 index 0000000..c1d1b53 --- /dev/null +++ b/packages/core/src/parser.ts @@ -0,0 +1,345 @@ +/** + * Trace parser — accepts untrusted input in JSON Object, JSONL, or bare array + * format and produces normalized `Event` objects with warnings for malformed + * entries. + * + * Security: + * - Safe JSON parsing with try/catch + * - Input size limit (10 MB) + * - Event count limit (10,000) + * - Zod schema validation prevents prototype pollution + * - Malformed individual events are skipped with a `ParseWarning` + * - Structural errors (invalid JSON, missing events array) fail fast + * + * @see ADR-0002 (input trace formats) + * @see ADR-0007 (malformed trace handling) + * @see docs/trace-format-spec.md + */ + +import { + bareArraySchema, + rawOcppMessageSchema, + traceEventInputSchema, + traceSchema, +} from './schemas.js'; +import { normalizeEvents } from './normalizer.js'; +import type { Event, ParseResult, ParseWarning, TraceEventInput } from './types.js'; + +// --------------------------------------------------------------------------- +// Limits (ADR-0007, docs/trace-format-spec.md) +// --------------------------------------------------------------------------- + +/** Maximum input size in bytes (10 MB). */ +export const MAX_INPUT_SIZE_BYTES = 10 * 1024 * 1024; + +/** Maximum number of events after parsing. */ +export const MAX_EVENT_COUNT = 10_000; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** Error thrown when input exceeds size limits or has structural problems. */ +export class ParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'ParseError'; + } +} + +// --------------------------------------------------------------------------- +// Input size validation +// --------------------------------------------------------------------------- + +/** + * Validate the input size before any parsing. + * @throws {ParseError} if the input exceeds the maximum size. + */ +function validateInputSize(input: string): void { + const byteLength = Buffer.byteLength(input, 'utf8'); + if (byteLength > MAX_INPUT_SIZE_BYTES) { + throw new ParseError( + `Input size (${byteLength} bytes) exceeds the maximum allowed size ` + + `(${MAX_INPUT_SIZE_BYTES} bytes).`, + ); + } +} + +/** + * Validate the event count after parsing. + * @throws {ParseError} if the event count exceeds the maximum. + */ +function validateEventCount(events: TraceEventInput[]): void { + if (events.length > MAX_EVENT_COUNT) { + throw new ParseError( + `Event count (${events.length}) exceeds the maximum allowed count ` + `(${MAX_EVENT_COUNT}).`, + ); + } +} + +// --------------------------------------------------------------------------- +// Format detection +// --------------------------------------------------------------------------- + +/** + * Detect the trace format by examining the input string. + * + * - JSONL: multiple lines, first non-empty line starts with `{` and parses as JSON + * - JSON Object: starts with `{` + * - Bare Array: starts with `[` + * + * This function does NOT validate the full content — it just determines + * which parsing strategy to use. + */ +export type TraceFormat = 'json-object' | 'jsonl' | 'bare-array'; + +function detectFormat(input: string): TraceFormat { + const trimmed = input.trim(); + + if (trimmed.startsWith('{')) { + // Could be JSON Object format or JSONL starting with an event object. + // JSONL has multiple lines; a JSON Object is a single JSON value. + // We check if the trimmed input contains a newline followed by another `{` + // that is NOT inside a string (simplified check: if the last non-whitespace + // character is `}`, and there's content after the first `}`... but that's + // complex. Simpler: try parsing as a single JSON value first, and if that + // fails, try JSONL. + return 'json-object'; + } + + if (trimmed.startsWith('[')) { + return 'bare-array'; + } + + // Default to JSONL for other cases (each line is a JSON object) + return 'jsonl'; +} + +// --------------------------------------------------------------------------- +// JSONL parsing +// --------------------------------------------------------------------------- + +/** + * Parse JSONL input: one event per line. + * Blank lines are ignored. Malformed lines produce warnings. + * + * @returns array of valid TraceEventInput and warnings for malformed lines. + */ +function parseJsonl(input: string): { events: TraceEventInput[]; warnings: ParseWarning[] } { + const lines = input.split('\n'); + const events: TraceEventInput[] = []; + const warnings: ParseWarning[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line === undefined) continue; + const trimmed = line.trim(); + if (trimmed === '') { + continue; // Skip blank lines + } + + try { + const parsed: unknown = JSON.parse(trimmed); + const result = traceEventInputSchema.safeParse(parsed); + if (result.success) { + events.push(result.data); + } else { + warnings.push({ + index: i, + message: `Line ${i + 1}: invalid event structure — ${result.error.issues[0]?.message ?? 'unknown error'}`, + rawInput: trimmed.slice(0, 200), + }); + } + } catch (e) { + warnings.push({ + index: i, + message: `Line ${i + 1}: invalid JSON — ${e instanceof Error ? e.message : 'unknown error'}`, + rawInput: trimmed.slice(0, 200), + }); + } + } + + return { events, warnings }; +} + +// --------------------------------------------------------------------------- +// Bare array parsing +// --------------------------------------------------------------------------- + +/** + * Parse bare array format: `[ [2, "id", "Action", {...}], [3, "id", {...}], ... ]` + * + * Each element is a raw OCPP message. Direction is inferred; timestamp is null. + */ +function parseBareArray(input: string): { events: TraceEventInput[]; warnings: ParseWarning[] } { + const warnings: ParseWarning[] = []; + + try { + const parsed: unknown = JSON.parse(input); + const result = bareArraySchema.safeParse(parsed); + if (!result.success) { + throw new ParseError( + `Invalid bare array format — ${result.error.issues[0]?.message ?? 'unknown error'}`, + ); + } + + // Convert raw messages to TraceEventInput (with null timestamp, no direction) + const events: TraceEventInput[] = result.data.map((message) => ({ + timestamp: null, + direction: undefined, + message, + })); + + return { events, warnings }; + } catch (e) { + if (e instanceof ParseError) throw e; + throw new ParseError( + `Invalid JSON in bare array format — ${e instanceof Error ? e.message : 'unknown error'}`, + ); + } +} + +// --------------------------------------------------------------------------- +// JSON Object parsing +// --------------------------------------------------------------------------- + +/** + * Parse JSON Object format: `{ "traceId": ..., "metadata": ..., "events": [...] }` + * + * Also handles the case where the JSON Object contains an array of raw messages + * in the `events` field (degenerate — treated as bare messages without timestamp/direction). + */ +function parseJsonObject(input: string): { events: TraceEventInput[]; warnings: ParseWarning[] } { + let parsed: unknown; + + try { + parsed = JSON.parse(input); + } catch (e) { + throw new ParseError(`Invalid JSON — ${e instanceof Error ? e.message : 'unknown error'}`); + } + + // If the parsed value is an array, treat it as a bare array (degenerate JSON Object) + if (Array.isArray(parsed)) { + return parseBareArray(input); + } + + const result = traceSchema.safeParse(parsed); + if (!result.success) { + throw new ParseError( + `Invalid trace structure — ${result.error.issues[0]?.message ?? 'unknown error'}`, + ); + } + + const trace = result.data; + const warnings: ParseWarning[] = []; + const events: TraceEventInput[] = []; + + // Validate each event individually — malformed events are skipped with warnings + for (let i = 0; i < trace.events.length; i++) { + const eventInput = trace.events[i]; + if (eventInput === undefined) continue; + + // Re-validate the individual message (the schema already validated the + // overall structure, but we want to catch edge cases in the message array) + const msgResult = rawOcppMessageSchema.safeParse(eventInput.message); + if (!msgResult.success) { + warnings.push({ + index: i, + message: `Event ${i + 1}: invalid OCPP message — ${msgResult.error.issues[0]?.message ?? 'unknown error'}`, + }); + continue; + } + + events.push({ + timestamp: eventInput.timestamp ?? null, + direction: eventInput.direction, + message: msgResult.data, + }); + } + + if (events.length === 0) { + throw new ParseError('Trace contains no valid events after parsing.'); + } + + return { events, warnings }; +} + +// --------------------------------------------------------------------------- +// parseTrace() +// --------------------------------------------------------------------------- + +/** + * Parse a trace from a raw string input. + * + * Accepts: + * - JSON Object format (metadata + events array) + * - JSONL format (one event per line) + * - Bare array format (array of raw OCPP message arrays) + * + * @param input - Raw trace string (untrusted) + * @returns ParseResult with normalized events and warnings for malformed entries + * @throws {ParseError} for structural errors (invalid JSON, missing events, size/count limits exceeded) + * + * @see ADR-0002, ADR-0007 + */ +export function parseTrace(input: string): ParseResult { + // Validate input size + validateInputSize(input); + + const trimmed = input.trim(); + if (trimmed === '') { + throw new ParseError('Input is empty.'); + } + + // Detect format and parse accordingly + const format = detectFormat(trimmed); + + let events: TraceEventInput[]; + let warnings: ParseWarning[]; + + if (format === 'bare-array') { + const result = parseBareArray(trimmed); + events = result.events; + warnings = result.warnings; + } else if (format === 'jsonl') { + const result = parseJsonl(trimmed); + events = result.events; + warnings = result.warnings; + } else { + // json-object — but it might actually be JSONL if JSON.parse fails + try { + const result = parseJsonObject(trimmed); + events = result.events; + warnings = result.warnings; + } catch (e) { + if (e instanceof ParseError) { + // If JSON Object parsing fails, try JSONL as a fallback + // (input starts with `{` but might be JSONL with event objects) + const jsonlResult = parseJsonl(trimmed); + if (jsonlResult.events.length > 0) { + events = jsonlResult.events; + warnings = jsonlResult.warnings; + } else { + throw e; + } + } else { + throw e; + } + } + } + + // Validate event count + validateEventCount(events); + + if (events.length === 0) { + throw new ParseError('Trace contains no valid events.'); + } + + // Normalize events into canonical Event objects + const normalizedEvents: Event[] = normalizeEvents(events); + + return { + events: normalizedEvents, + warnings, + }; +} diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts new file mode 100644 index 0000000..4d5a3e7 --- /dev/null +++ b/packages/core/src/schemas.ts @@ -0,0 +1,94 @@ +/** + * Zod schemas for OCPP DebugKit trace input validation. + * + * These schemas validate untrusted input at the boundary (ADR-0007). + * They prevent prototype pollution and ensure structural correctness + * before any processing occurs. + * + * @see docs/trace-format-spec.md + * @see ADR-0002 (input trace formats) + * @see ADR-0003 (canonical event model) + * @see ADR-0007 (malformed trace handling) + */ + +import { z } from 'zod'; + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +export const directionSchema = z.enum(['CS_TO_CSMS', 'CSMS_TO_CS', 'UNKNOWN']); + +export const messageTypeSchema = z.enum(['Call', 'CallResult', 'CallError']); + +/** + * Raw OCPP 1.6 JSON message array. + * - Call (2): [2, UniqueId, Action, Payload] + * - CallResult (3): [3, UniqueId, Payload] + * - CallError (4): [4, UniqueId, ErrorCode, ErrorDescription, ErrorDetails] + * + * We validate the first two elements strictly (MessageTypeId + UniqueId) + * and accept arbitrary payload data. + */ +export const rawOcppMessageSchema = z + .tuple([z.number(), z.string()]) + .rest(z.unknown()) + .refine((msg) => msg[0] === 2 || msg[0] === 3 || msg[0] === 4, { + message: 'MessageTypeId must be 2 (Call), 3 (CallResult), or 4 (CallError)', + }) + .refine((msg) => (msg[0] === 2 ? msg.length >= 4 : true), { + message: 'Call message must have at least 4 elements: [2, UniqueId, Action, Payload]', + }) + .refine((msg) => (msg[0] === 3 ? msg.length >= 3 : true), { + message: 'CallResult message must have at least 3 elements: [3, UniqueId, Payload]', + }) + .refine((msg) => (msg[0] === 4 ? msg.length >= 5 : true), { + message: + 'CallError message must have at least 5 elements: [4, UniqueId, ErrorCode, ErrorDescription, ErrorDetails]', + }) + .refine((msg) => (msg[0] === 2 ? typeof msg[2] === 'string' : true), { + message: 'Call message Action (index 2) must be a string', + }) + .refine((msg) => (msg[0] === 4 ? typeof msg[2] === 'string' : true), { + message: 'CallError ErrorCode (index 2) must be a string', + }) + .refine((msg) => (msg[0] === 4 ? typeof msg[3] === 'string' : true), { + message: 'CallError ErrorDescription (index 3) must be a string', + }); + +// --------------------------------------------------------------------------- +// Trace Event Input (ADR-0002, ADR-0003) +// --------------------------------------------------------------------------- + +export const traceEventInputSchema = z.object({ + timestamp: z.union([z.string(), z.number()]).nullable().optional(), + direction: directionSchema.optional(), + message: rawOcppMessageSchema, +}); + +// --------------------------------------------------------------------------- +// Trace Metadata (ADR-0002) +// --------------------------------------------------------------------------- + +export const traceMetadataSchema = z.object({ + stationId: z.string().optional(), + ocppVersion: z.string().optional(), + source: z.string().optional(), + description: z.string().optional(), +}); + +// --------------------------------------------------------------------------- +// JSON Object Trace Format (ADR-0002) +// --------------------------------------------------------------------------- + +export const traceSchema = z.object({ + traceId: z.string().optional(), + metadata: traceMetadataSchema.optional(), + events: z.array(traceEventInputSchema).min(1, { message: 'events array must not be empty' }), +}); + +// --------------------------------------------------------------------------- +// Bare Array Format (degenerate — array of raw OCPP messages) +// --------------------------------------------------------------------------- + +export const bareArraySchema = z.array(rawOcppMessageSchema).min(1); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 65ec6a2..e656753 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -141,3 +141,72 @@ export interface ParseResult { events: Event[]; warnings: ParseWarning[]; } + +// --------------------------------------------------------------------------- +// Failure Detection (ADR-0003) +// --------------------------------------------------------------------------- + +/** Severity of a detected failure. */ +export type FailureSeverity = 'critical' | 'warning' | 'info'; + +/** Failure rule codes implemented in v0.1. */ +export type FailureCode = + 'FAILED_AUTHORIZATION' | 'CONNECTOR_FAULT' | 'STATION_OFFLINE_DURING_SESSION'; + +/** + * A detected failure in a trace. + */ +export interface Failure { + /** Failure rule code. */ + code: FailureCode; + /** Human-readable description. */ + description: string; + /** Severity level. */ + severity: FailureSeverity; + /** Event IDs associated with the failure. */ + eventIds: string[]; + /** Suggested next steps for resolution. */ + suggestedSteps: string[]; +} + +// --------------------------------------------------------------------------- +// Session Summary +// --------------------------------------------------------------------------- + +/** Summary statistics for a charging session. */ +export interface SessionSummary { + sessionId: string; + stationId: string; + connectorId: number | null; + transactionId: number | null; + status: 'active' | 'completed' | 'aborted'; + eventCount: number; + durationMs: number | null; + failureCount: number; + /** Ordered list of actions in the session. */ + actionSequence: string[]; +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** Result of validating a single OCPP message. */ +export interface ValidationResult { + valid: boolean; + errors: string[]; +} + +// --------------------------------------------------------------------------- +// Scenario +// --------------------------------------------------------------------------- + +/** A scenario fixture for testing the analysis engine. */ +export interface Scenario { + name: string; + description: string; + /** The trace data to analyze. */ + trace: Trace; + /** Failure codes expected to be detected. */ + expectedFailures: FailureCode[]; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 148143e..60f32c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,6 +37,10 @@ importers: version: 3.2.7(@types/node@22.20.0) packages/core: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: typescript: specifier: ^5.7.0 @@ -1368,6 +1372,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@babel/runtime@7.29.7': {} @@ -2658,3 +2665,5 @@ snapshots: word-wrap@1.2.5: {} yocto-queue@0.1.0: {} + + zod@4.4.3: {}