Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/__tests__/contracts-schema-public.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { AppError } from '../index.ts';
import {
defaultHintForCode,
daemonCommandRequestSchema,
daemonRuntimeSchema,
centerOfRect,
jsonRpcRequestSchema,
leaseAllocateSchema,
leaseHeartbeatSchema,
leaseReleaseSchema,
normalizeError,
type AppErrorCode,
type Rect,
type SnapshotNode,
} from '../contracts.ts';

const invalidArgsCode = 'INVALID_ARGS' satisfies AppErrorCode;
const rect = { x: 1, y: 2, width: 3, height: 4 } satisfies Rect;
const node = {
index: 0,
Expand All @@ -21,6 +28,16 @@ const node = {
rect,
} satisfies SnapshotNode;

test('public contracts error helpers do not load diagnostics module', () => {
const errorsSource = fs.readFileSync(
path.join(import.meta.dirname, '..', 'utils', 'errors.ts'),
'utf8',
);

assert.doesNotMatch(errorsSource, /['"]\.\/diagnostics\.ts['"]/);
assert.doesNotMatch(errorsSource, /node:/);
});

test('public contract schemas validate daemon requests and lease payloads', () => {
const runtime = daemonRuntimeSchema.parse({
platform: 'ios',
Expand Down Expand Up @@ -70,6 +87,17 @@ test('public contract schemas validate daemon requests and lease payloads', () =
assert.equal(node.ref, 'e1');
});

test('public contract exports normalize and hint app errors', () => {
const normalized = normalizeError(new AppError(invalidArgsCode, 'Invalid command'));

assert.equal(normalized.code, invalidArgsCode);
assert.equal(normalized.hint, defaultHintForCode(invalidArgsCode));
assert.equal(
defaultHintForCode('UNKNOWN'),
'Retry with --debug and inspect diagnostics log for details.',
);
});

test('public contract schemas reject invalid payloads', () => {
assert.throws(
() =>
Expand Down
3 changes: 3 additions & 0 deletions src/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export type { AppErrorCode } from './utils/errors.ts';
export { defaultHintForCode, normalizeError } from './utils/errors.ts';

export type SessionRuntimeHints = {
platform?: 'ios' | 'android';
metroHost?: string;
Expand Down
58 changes: 1 addition & 57 deletions src/utils/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { redactDiagnosticData } from './redaction.ts';

type DiagnosticLevel = 'info' | 'warn' | 'error' | 'debug';

Expand Down Expand Up @@ -33,11 +34,6 @@ type DiagnosticsScope = DiagnosticsScopeOptions & {

const diagnosticsStorage = new AsyncLocalStorage<DiagnosticsScope>();

const SENSITIVE_KEY_RE =
/(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i;
const SENSITIVE_VALUE_RE =
/(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i;

export function createRequestId(): string {
return crypto.randomBytes(8).toString('hex');
}
Expand Down Expand Up @@ -161,58 +157,6 @@ export function flushDiagnosticsToSessionFile(options: { force?: boolean } = {})
}
}

export function redactDiagnosticData<T>(input: T): T {
return redactValue(input, new WeakSet<object>()) as T;
}

function redactValue(value: unknown, seen: WeakSet<object>, keyHint?: string): unknown {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return redactString(value, keyHint);
if (typeof value !== 'object') return value;

if (seen.has(value as object)) return '[Circular]';
seen.add(value as object);

if (Array.isArray(value)) {
return value.map((entry) => redactValue(entry, seen));
}

const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEY_RE.test(key)) {
output[key] = '[REDACTED]';
continue;
}
output[key] = redactValue(entry, seen, key);
}
return output;
}

function redactString(value: string, keyHint?: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]';
if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]';
const maskedUrl = redactUrl(trimmed);
if (maskedUrl) return maskedUrl;
if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...<truncated>`;
return trimmed;
}

function redactUrl(value: string): string | null {
try {
const parsed = new URL(value);
if (parsed.search) parsed.search = '?REDACTED';
if (parsed.username || parsed.password) {
parsed.username = 'REDACTED';
parsed.password = 'REDACTED';
}
return parsed.toString();
} catch {
return null;
}
}

function sanitizePathPart(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]/g, '_');
}
10 changes: 5 additions & 5 deletions src/utils/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { redactDiagnosticData } from './diagnostics.ts';
import { redactDiagnosticData } from './redaction.ts';

type ErrorCode =
export type AppErrorCode =
| 'INVALID_ARGS'
| 'DEVICE_NOT_FOUND'
| 'TOOL_MISSING'
Expand Down Expand Up @@ -28,11 +28,11 @@ export type NormalizedError = {
};

export class AppError extends Error {
code: ErrorCode;
code: AppErrorCode;
details?: AppErrorDetails;
cause?: unknown;

constructor(code: ErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
constructor(code: AppErrorCode, message: string, details?: AppErrorDetails, cause?: unknown) {
super(message);
this.code = code;
this.details = details;
Expand Down Expand Up @@ -115,7 +115,7 @@ function stripDiagnosticMeta(
return Object.keys(output).length > 0 ? output : undefined;
}

function defaultHintForCode(code: string): string | undefined {
export function defaultHintForCode(code: string): string | undefined {
switch (code) {
case 'INVALID_ARGS':
return 'Check command arguments and run --help for usage examples.';
Expand Down
56 changes: 56 additions & 0 deletions src/utils/redaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const SENSITIVE_KEY_RE =
/(token|secret|password|authorization|cookie|api[_-]?key|access[_-]?key|private[_-]?key)/i;
const SENSITIVE_VALUE_RE =
/(bearer\s+[a-z0-9._-]+|(?:api[_-]?key|token|secret|password)\s*[=:]\s*\S+)/i;

export function redactDiagnosticData<T>(input: T): T {
return redactValue(input, new WeakSet<object>()) as T;
}

function redactValue(value: unknown, seen: WeakSet<object>, keyHint?: string): unknown {
if (value === null || value === undefined) return value;
if (typeof value === 'string') return redactString(value, keyHint);
if (typeof value !== 'object') return value;

if (seen.has(value as object)) return '[Circular]';
seen.add(value as object);

if (Array.isArray(value)) {
return value.map((entry) => redactValue(entry, seen));
}

const output: Record<string, unknown> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (SENSITIVE_KEY_RE.test(key)) {
output[key] = '[REDACTED]';
continue;
}
output[key] = redactValue(entry, seen, key);
}
return output;
}

function redactString(value: string, keyHint?: string): string {
const trimmed = value.trim();
if (!trimmed) return value;
if (keyHint && SENSITIVE_KEY_RE.test(keyHint)) return '[REDACTED]';
if (SENSITIVE_VALUE_RE.test(trimmed)) return '[REDACTED]';
const maskedUrl = redactUrl(trimmed);
if (maskedUrl) return maskedUrl;
if (trimmed.length > 400) return `${trimmed.slice(0, 200)}...<truncated>`;
return trimmed;
}

function redactUrl(value: string): string | null {
try {
const parsed = new URL(value);
if (parsed.search) parsed.search = '?REDACTED';
if (parsed.username || parsed.password) {
parsed.username = 'REDACTED';
parsed.password = 'REDACTED';
}
return parsed.toString();
} catch {
return null;
}
}
Loading