Skip to content
Draft
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
91 changes: 91 additions & 0 deletions src/collaboration/awareness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,97 @@ describe('collaboration awareness validation', () => {
expect(() => serializeCollaborationUser(user)).toThrow(expected);
});

it.each([
['userId', 42, 'collaboration userId must be a string'],
['displayName', {}, 'collaboration displayName must be a string'],
['cursorColor', null, 'collaboration cursorColor must be a string'],
] as const)('rejects malformed %s before normalization', (field, value, message) => {
expect(() =>
serializeCollaborationUser({
userId: 'editor-alice',
displayName: 'Alice',
cursorColor: '#123456',
[field]: value,
} as never),
).toThrowError(new Error(message));
});

it('bounds normalized public identity without splitting Unicode code points', () => {
const boundedName = `${'A'.repeat(79)}😀`;
const boundedId = `${'a'.repeat(79)}😀`;

expect(
serializeCollaborationUser({
userId: boundedId,
displayName: `${boundedName}tail`,
cursorColor: '#123456',
}),
).toEqual({ id: boundedId, name: boundedName, color: '#123456' });

const arrayFrom = vi.spyOn(Array, 'from');
expect(() =>
serializeCollaborationUser({
userId: `editor-${'a'.repeat(74)}`,
displayName: 'Alice',
cursorColor: '#123456',
}),
).toThrow(/userId.*80/);
expect(arrayFrom).not.toHaveBeenCalled();
arrayFrom.mockRestore();
});

it.each(['userId', 'displayName', 'cursorColor'] as const)(
'rejects oversized %s before normalization',
(field) => {
const originalTrim = String.prototype.trim;
let oversizedTrimObserved = false;
const trimSpy = vi
.spyOn(String.prototype, 'trim')
.mockImplementation(function (this: string) {
if (this.length > 1_024) oversizedTrimObserved = true;
return originalTrim.call(this);
});

try {
expect(() =>
serializeCollaborationUser({
userId: 'editor-alice',
displayName: 'Alice',
cursorColor: '#123456',
[field]: ' '.repeat(1_025),
}),
).toThrow();
expect(oversizedTrimObserved).toBe(false);
} finally {
trimSpy.mockRestore();
}
},
);

it.each(['userId', 'displayName', 'cursorColor'] as const)(
'redacts hostile %s property failures',
(field) => {
const privateFailure = { marker: 'private-local-user-sentinel' };
const user = new Proxy(
{
userId: 'editor-alice',
displayName: 'Alice',
cursorColor: '#123456',
},
{
get(target, property, receiver) {
if (property === field) throw privateFailure;
return Reflect.get(target, property, receiver);
},
},
);

expect(() => serializeCollaborationUser(user)).toThrowError(
new Error(`collaboration ${field} must be a string`),
);
},
);

it('allows collaboration without an awareness provider', () => {
expect(() =>
assertCollaborationConfiguration(undefined, undefined),
Expand Down
70 changes: 67 additions & 3 deletions src/collaboration/awareness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,62 @@ const CURSOR_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
const NUMERIC_IDENTIFIER_PATTERN = /^\d+$/;
const FALLBACK_CURSOR_COLOR = '#475569';
const MAX_CURSOR_LABEL_LENGTH = 80;
const MAX_PUBLIC_IDENTIFIER_LENGTH = 80;
const MAX_LOCAL_FIELD_SOURCE_LENGTH = 1_024;
type CollaborationUserField = 'userId' | 'displayName' | 'cursorColor';

/** Reject malformed or oversized local identity fields before normalization. */
function assertCollaborationUserStringField(
field: CollaborationUserField,
value: unknown,
): asserts value is string {
if (typeof value !== 'string') {
throw new Error(`collaboration ${field} must be a string`);
}
if (value.length > MAX_LOCAL_FIELD_SOURCE_LENGTH) {
throw new Error(
`collaboration ${field} must be at most ${MAX_LOCAL_FIELD_SOURCE_LENGTH} UTF-16 code units before normalization`,
);
}
}

/** Read one host-owned local identity field without leaking getter failures. */
function readCollaborationUserStringField(
user: CollaborationUser,
field: CollaborationUserField,
): string {
let value: unknown;
try {
value = user[field];
} catch {
throw new Error(`collaboration ${field} must be a string`);
}
assertCollaborationUserStringField(field, value);
return value;
}

/** Trim and bound a public cursor label without splitting Unicode code points. */
function truncateCursorLabel(value: string): string {
const trimmed = value.trim();
let bounded = '';
let count = 0;
for (const codePoint of trimmed) {
if (count >= MAX_CURSOR_LABEL_LENGTH) break;
bounded += codePoint;
count += 1;
}
return bounded;
}

/** Return whether public awareness metadata exceeds its Unicode code-point bound. */
function exceedsPublicIdentifierLength(value: string): boolean {
let count = 0;
for (const _codePoint of value) {
count += 1;
if (count > MAX_PUBLIC_IDENTIFIER_LENGTH) return true;
}
return false;
}

/** Read and validate the host-owned awareness capability without leaking failures. */
function readCompatibleCollaborationAwareness(
Expand Down Expand Up @@ -62,16 +118,24 @@ function readCompatibleCollaborationAwareness(
export function serializeCollaborationUser(
user: CollaborationUser,
): CollaborationCursorUser {
const id = user.userId.trim();
const name = user.displayName.trim();
const color = user.cursorColor.trim();
const sourceId = readCollaborationUserStringField(user, 'userId');
const sourceName = readCollaborationUserStringField(user, 'displayName');
const sourceColor = readCollaborationUserStringField(user, 'cursorColor');
const id = sourceId.trim();
const name = truncateCursorLabel(sourceName);
const color = sourceColor.trim();

if (id === '') {
throw new Error('collaboration userId must not be empty');
}
if (NUMERIC_IDENTIFIER_PATTERN.test(id)) {
throw new Error('collaboration userId must be descriptive and nonnumeric');
}
if (exceedsPublicIdentifierLength(id)) {
throw new Error(
'collaboration userId must be at most 80 Unicode code points',
);
}
if (name === '') {
throw new Error('collaboration displayName must not be empty');
}
Expand Down
Loading