diff --git a/device-tree.js b/device-tree.js index abbdbd9..9f8b4ff 100644 --- a/device-tree.js +++ b/device-tree.js @@ -1,6 +1,9 @@ 'use strict'; (function exposeDeviceTree(globalScope) { + const { toWellFormedString } = typeof module !== 'undefined' && module.exports + ? require('./well-formed-string') + : globalScope.AptWellFormedString; const SPECIAL = Object.freeze({ ORPHAN_SITE: '__orphaned__', UNGROUPED: '__ungrouped__', @@ -9,7 +12,7 @@ }); function text(value) { - return value === null || value === undefined ? '' : String(value).trim(); + return value === null || value === undefined ? '' : toWellFormedString(value).trim(); } function normalized(value) { @@ -17,7 +20,7 @@ } function keyPart(value) { - const encoded = encodeURIComponent(String(value)); + const encoded = encodeURIComponent(toWellFormedString(value)); return `${encoded.length}:${encoded}`; } @@ -109,7 +112,11 @@ const site = makeSite(identity.uniqueId, text(safe.name) || identity.canonicalId, ordinal, { canonicalId: identity.canonicalId, pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)), - source: safe, + source: { + id: identity.canonicalId, + name: text(safe.name), + pendingDeletionStart: text(safe.pendingDeletionStart) || null, + }, }); sites.push(site); registerRow(site); @@ -144,7 +151,12 @@ canonicalId: identity.canonicalId, rawParentId: parentId, pendingDeletion: safe.pendingDeletion === true || Boolean(text(safe.pendingDeletionStart)), - source: safe, + source: { + id: identity.canonicalId, + name: text(safe.name), + parentId, + pendingDeletionStart: text(safe.pendingDeletionStart) || null, + }, }); site.groups.push(group); registerRow(group); @@ -206,10 +218,21 @@ } const name = text(safe.name) || identity.canonicalId; + const source = { + id: identity.canonicalId, + name, + type: text(safe.type) || null, + model: text(safe.model) || null, + address: text(safe.address) || null, + siteId: directSiteId || null, + deviceGroupId: requestedGroupId || null, + displayStatus: text(safe.displayStatus) || null, + localStorage: typeof safe.localStorage === 'boolean' ? safe.localStorage : null, + }; const camera = { kind: 'camera', id: identity.uniqueId, canonicalId: identity.canonicalId, key: cameraKey, name, normalizedName: normalized(name), ordinal, - parentKey: group.key, site, group, hierarchyStatus, source: safe, + parentKey: group.key, site, group, hierarchyStatus, source, }; camera.searchCorpus = normalized([ name, identity.canonicalId, safe.model, safe.type, safe.address, site.name, group.name, diff --git a/index.html b/index.html index 66d89d1..e8b21cb 100644 --- a/index.html +++ b/index.html @@ -108,6 +108,7 @@ + diff --git a/main.js b/main.js index 29b18c6..c12e1f8 100644 --- a/main.js +++ b/main.js @@ -118,7 +118,10 @@ function startBridgeServer() { const handler = createBridgeHandler({ bridgeAuth: pairingController.bridgeAuth, sessionStore: runtime.sessionStore, - onConnectionStateChanged: sendConnectionState, + onConnectionStateChanged: () => { + runtime.onSessionChanged(); + sendConnectionState(); + }, }); bridgeServer = http.createServer((request, response) => { handler(request, response).catch(() => { diff --git a/package.json b/package.json index d222b54..00a80c8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "preload.js", "renderer.js", "renderer-controller.js", + "well-formed-string.js", "device-tree.js", "sidebar-controller.js", "sidebar-view.js", diff --git a/src/device-projection.js b/src/device-projection.js index fdebb16..88285d5 100644 --- a/src/device-projection.js +++ b/src/device-projection.js @@ -1,6 +1,7 @@ 'use strict'; const { validateDeviceId } = require('./proxy-launch'); +const { toWellFormedString } = require('../well-formed-string'); const MAX_PROJECTED_PAYLOAD_BYTES = 8 * 1024 * 1024; @@ -11,11 +12,11 @@ function projectionError(code, message) { } function nullableString(value) { - return typeof value === 'string' ? value : null; + return typeof value === 'string' ? toWellFormedString(value) : null; } function nullableId(value) { - return typeof value === 'string' && value.length > 0 ? value : null; + return typeof value === 'string' && value.length > 0 ? toWellFormedString(value) : null; } function nullableBoolean(value) { @@ -78,7 +79,9 @@ function projectArray(values, projector) { } function safeMetadataError(value) { - return typeof value === 'string' && value.length > 0 ? value.slice(0, 512) : null; + return typeof value === 'string' && value.length > 0 + ? Array.from(toWellFormedString(value)).slice(0, 512).join('') + : null; } function projectDeviceHierarchy({ devices = [], sites = [], groups = [], metadataErrors = {} } = {}) { diff --git a/src/electron-runtime.js b/src/electron-runtime.js index 9c0324a..832af90 100644 --- a/src/electron-runtime.js +++ b/src/electron-runtime.js @@ -214,6 +214,8 @@ class AppRuntime { this.openExternal = openExternal; this.discoveryTimeoutMs = discoveryTimeoutMs; this.allowedDeviceIds = new Set(); + this.allowedDeviceDiscovery = null; + this.discoveryGeneration = 0; this.proxyByDevice = new Map(); } @@ -233,16 +235,55 @@ class AppRuntime { return tracked.filter((proxy) => this.proxyByDevice.get(proxy.deviceId) === proxy.processId); } - _setAllowedDevices(devices) { + _setAllowedDevices(devices, discovery) { this.allowedDeviceIds = new Set(devices.map((device) => device.id)); + this.allowedDeviceDiscovery = discovery; + } + + _beginDiscovery() { + const state = this.sessionStore.describe(); + this.allowedDeviceIds.clear(); + this.allowedDeviceDiscovery = null; + return Object.freeze({ + generation: ++this.discoveryGeneration, + connected: state.connected, + origin: state.origin, + }); + } + + _isCurrentDiscovery(discovery) { + const state = this.sessionStore.describe(); + return discovery.generation === this.discoveryGeneration && + discovery.connected === state.connected && discovery.origin === state.origin; + } + + _staleDiscoveryResult() { + return { + success: false, + stale: true, + hierarchy: { devices: [], sites: [], groups: [] }, + message: 'Alta device discovery result is stale', + }; + } + + invalidateDiscovery() { + this.discoveryGeneration += 1; + this.allowedDeviceIds.clear(); + this.allowedDeviceDiscovery = null; + } + + onSessionChanged() { + this.invalidateDiscovery(); } async getDevices() { + const discovery = this._beginDiscovery(); try { const hierarchy = projectDeviceHierarchy({ devices: await this.altaClient.getDevices(), sites: [], groups: [], }); - this._setAllowedDevices(hierarchy.devices); + if (!this._isCurrentDiscovery(discovery)) return this._staleDiscoveryResult(); + this._setAllowedDevices(hierarchy.devices, discovery); return { success: true, devices: hierarchy.devices }; } catch (error) { return { success: false, devices: [], message: safeErrorMessage(error, 'Failed to get devices') }; @@ -268,6 +309,7 @@ class AppRuntime { } async getDeviceHierarchy() { + const discoveryState = this._beginDiscovery(); let timer; try { const discovery = Promise.allSettled([ @@ -298,9 +340,11 @@ class AppRuntime { groups: groupResult.status === 'fulfilled' ? groupResult.value : [], metadataErrors, }); - this._setAllowedDevices(hierarchy.devices); + if (!this._isCurrentDiscovery(discoveryState)) return this._staleDiscoveryResult(); + this._setAllowedDevices(hierarchy.devices, discoveryState); return { success: true, hierarchy }; } catch (error) { + if (!this._isCurrentDiscovery(discoveryState)) return this._staleDiscoveryResult(); this.allowedDeviceIds.clear(); const message = error && error.code === 'DISCOVERY_TIMEOUT' ? 'Alta device discovery timed out' @@ -324,7 +368,8 @@ class AppRuntime { const validatedId = validateDeviceId(deviceId); const validatedUsername = validateUsername(username); this._reconcileProxies(); - if (!this.allowedDeviceIds.has(validatedId)) { + if (!this.allowedDeviceDiscovery || !this._isCurrentDiscovery(this.allowedDeviceDiscovery) || + !this.allowedDeviceIds.has(validatedId)) { return { success: false, message: 'Select a device from the current Alta device list.' }; } if (this.proxyByDevice.has(validatedId)) { @@ -364,6 +409,7 @@ class AppRuntime { } async disconnect() { + this.invalidateDiscovery(); const tracked = this._reconcileProxies(); await Promise.all(tracked.map(async (proxy) => { try { diff --git a/test/device-projection.test.js b/test/device-projection.test.js index 78722a9..3fa29c5 100644 --- a/test/device-projection.test.js +++ b/test/device-projection.test.js @@ -72,6 +72,32 @@ test('malformed hierarchy metadata does not hide valid cameras', () => { assert.equal(payload.diagnostics.sites.error, 'Unavailable'); }); +test('repairs lone UTF-16 surrogates without changing valid Unicode or hiding cameras', () => { + const payload = projectDeviceHierarchy({ + devices: [rawDevice(8, { + name: 'Front \ud800 camera', + model: 'Model \udc00', + address: 'Hall \ud800\udc00 / \udc00', + server_group_id: 'site-\ud800', + device_group_id: 'group-\udc00', + })], + sites: [{ id: 'site-\ud800', name: 'Valid \ud83d\udcf7 \udc00', pending_deletion_start: '\ud800' }], + groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parent_id: 'site-\ud800' }], + metadataErrors: { sites: 'warning \ud800', groups: 'warning \udc00' }, + }); + + assert.equal(payload.devices.length, 1); + assert.equal(payload.devices[0].name, 'Front \ufffd camera'); + assert.equal(payload.devices[0].model, 'Model \ufffd'); + assert.equal(payload.devices[0].address, 'Hall \ud800\udc00 / \ufffd'); + assert.equal(payload.devices[0].siteId, 'site-\ufffd'); + assert.equal(payload.devices[0].deviceGroupId, 'group-\ufffd'); + assert.deepEqual(payload.sites, [{ id: 'site-\ufffd', name: 'Valid \ud83d\udcf7 \ufffd', pendingDeletionStart: '\ufffd' }]); + assert.deepEqual(payload.groups, [{ id: 'group-\ufffd', name: 'Group \ud83d\udcf7 \ufffd', parentId: 'site-\ufffd', pendingDeletionStart: null }]); + assert.equal(payload.diagnostics.sites.error, 'warning \ufffd'); + assert.doesNotMatch(JSON.stringify(payload), /[\ud800-\udfff]/u); +}); + test('rejects a projected hierarchy payload above 8 MiB', () => { const devices = Array.from({ length: 10_000 }, (_, index) => rawDevice(index, { name: `Camera ${index} ${'x'.repeat(900)}`, diff --git a/test/device-tree.test.js b/test/device-tree.test.js index 39b950e..0920080 100644 --- a/test/device-tree.test.js +++ b/test/device-tree.test.js @@ -194,6 +194,33 @@ test('tenant and synthetic namespaces stay distinct for reserved and ambiguous I assert.ok([...model.rowByKey.keys()].every((key) => !/\s/.test(key))); }); +test('malformed Unicode metadata builds deterministically and remains searchable and visible', async () => { + const fixture = { + sites: [{ id: 'site-\ud800', name: 'Site \ud83d\udcf7 \udc00' }], + groups: [{ id: 'group-\udc00', name: 'Group \ud83d\udcf7 \ud800', parentId: 'site-\ud800' }], + devices: [{ + id: 'camera-valid', name: 'Lobby \ud800 camera', siteId: 'site-\ud800', + deviceGroupId: 'group-\udc00', address: 'Floor \udc00', + }], + }; + + const first = buildDeviceTree(fixture); + const second = buildDeviceTree(fixture); + assert.equal(first.cameraCount, 1); + assert.deepEqual([...first.rowByKey.keys()], [...second.rowByKey.keys()]); + assert.doesNotMatch([...first.rowByKey.keys()].join(''), /[\ud800-\udfff]/u); + const camera = [...first.cameraByKey.values()][0]; + assert.equal(camera.name, 'Lobby \ufffd camera'); + assert.equal(camera.site.name, 'Site \ud83d\udcf7 \ufffd'); + assert.equal(camera.group.name, 'Group \ud83d\udcf7 \ufffd'); + assert.equal(camera.source.address, 'Floor \ufffd'); + assert.doesNotMatch(JSON.stringify(camera.source), /[\ud800-\udfff]/u); + assert.match(camera.searchCorpus, /floor \ufffd/); + + const result = await createSearchRunner().search(first, 'lobby \ufffd'); + assert.deepEqual([...result.cameraKeys], [camera.key]); +}); + test('virtual window clamps an obsolete scroll offset after rows shrink', () => { const rows = [{ key: 'only-row' }]; const window = calculateVirtualWindow(rows, { diff --git a/test/runtime-contract.test.js b/test/runtime-contract.test.js index f2f3036..58664c6 100644 --- a/test/runtime-contract.test.js +++ b/test/runtime-contract.test.js @@ -162,6 +162,96 @@ test('runtime enforces a bounded whole-discovery deadline', async () => { }); }); +test('newest hierarchy discovery owns the launch allowlist when completions arrive out of order', async () => { + const sessionStore = createSessionStore(); + sessionStore.establish('https://customer.avasecurity.com', 'synthetic-cookie'); + const deviceA = '550e8400-e29b-41d4-a716-44665544000a'; + const deviceB = '550e8400-e29b-41d4-a716-44665544000b'; + const pending = []; + const launches = []; + const runtime = new AppRuntime({ + sessionStore, + altaClient: { + getDevices: () => new Promise((resolve) => pending.push(resolve)), + getDeviceSites: async () => [], + getDeviceGroups: async () => [], + }, + proxyManager: { + launchProxy(request) { + launches.push(request.deviceId); + return { processId: 5000 + launches.length, deviceId: request.deviceId, status: 'running' }; + }, + listTrackedProxies: () => [], + }, + }); + + const staleA = runtime.getDeviceHierarchy(); + const freshB = runtime.getDeviceHierarchy(); + pending[1]([{ guid: deviceB }]); + assert.equal((await freshB).success, true); + assert.equal((await runtime.launchProxy(deviceB, 'operator@example.com')).success, true); + pending[0]([{ guid: deviceA }]); + assert.deepEqual(await staleA, { + success: false, + stale: true, + hierarchy: { devices: [], sites: [], groups: [] }, + message: 'Alta device discovery result is stale', + }); + assert.equal((await runtime.launchProxy(deviceA, 'operator@example.com')).success, false); + assert.deepEqual(launches, [deviceB]); +}); + +test('disconnect and session changes invalidate pending discovery without repopulating the allowlist', async () => { + const deviceId = '550e8400-e29b-41d4-a716-44665544000c'; + for (const invalidate of ['disconnect', 'session-change']) { + const sessionStore = createSessionStore(); + sessionStore.establish('https://first.avasecurity.com', 'synthetic-cookie'); + let resolveDevices; + const runtime = new AppRuntime({ + sessionStore, + altaClient: { + getDevices: () => new Promise((resolve) => { resolveDevices = resolve; }), + getDeviceSites: async () => [], + getDeviceGroups: async () => [], + }, + proxyManager: { listTrackedProxies: () => [] }, + }); + const discovery = runtime.getDeviceHierarchy(); + if (invalidate === 'disconnect') { + assert.equal((await runtime.disconnect()).success, true); + } else { + sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie'); + runtime.onSessionChanged(); + } + resolveDevices([{ guid: deviceId }]); + assert.equal((await discovery).stale, true, invalidate); + assert.equal(runtime.allowedDeviceIds.size, 0, invalidate); + } +}); + +test('launch allowlist remains bound to the session origin that produced it', async () => { + const deviceId = '550e8400-e29b-41d4-a716-44665544000d'; + const sessionStore = createSessionStore(); + sessionStore.establish('https://first.avasecurity.com', 'synthetic-cookie'); + let launches = 0; + const runtime = new AppRuntime({ + sessionStore, + altaClient: { + getDevices: async () => [{ guid: deviceId }], + getDeviceSites: async () => [], + getDeviceGroups: async () => [], + }, + proxyManager: { + listTrackedProxies: () => [], + launchProxy() { launches += 1; }, + }, + }); + assert.equal((await runtime.getDeviceHierarchy()).success, true); + sessionStore.establish('https://second.avasecurity.com', 'replacement-cookie'); + assert.equal((await runtime.launchProxy(deviceId, 'operator@example.com')).success, false); + assert.equal(launches, 0); +}); + test('runtime rejects discovery deadlines above 60 seconds', () => { assert.throws(() => new AppRuntime({ discoveryTimeoutMs: 60_001 }), /discovery timeout/i); }); @@ -439,6 +529,7 @@ test('preload and renderer expose only narrow, credential-free contracts', () => assert.doesNotMatch(preload, /launchProxy:\s*\([^)]*(?:cookie|origin)/i); assert.doesNotMatch(renderer, /cookieValue|sessionData\.cookies|cookies\s*:/); assert.match(renderer, /state\.connected\s*&&\s*\(!wasConnected\s*\|\|\s*state\.origin\s*!==\s*previousOrigin\)/); + assert.match(read('main.js'), /onConnectionStateChanged:\s*\(\)\s*=>\s*\{\s*runtime\.onSessionChanged\(\)/); assert.doesNotMatch(html, /id="cookieKey"|updateProgress|Install Update/); assert.match(html, /id="altaUsername"/); assert.match(renderer, /openFixedReleasesPage/); diff --git a/well-formed-string.js b/well-formed-string.js new file mode 100644 index 0000000..3dfb60c --- /dev/null +++ b/well-formed-string.js @@ -0,0 +1,42 @@ +'use strict'; + +(function exposeWellFormedString(root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (root) root.AptWellFormedString = api; +}(typeof window !== 'undefined' ? window : undefined, function wellFormedStringFactory() { + function manualToWellFormed(value) { + let result = ''; + for (let index = 0; index < value.length; index += 1) { + const codeUnit = value.charCodeAt(index); + if (codeUnit >= 0xd800 && codeUnit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + result += value[index] + value[index + 1]; + index += 1; + } else { + result += '\ufffd'; + } + } else if (codeUnit >= 0xdc00 && codeUnit <= 0xdfff) { + result += '\ufffd'; + } else { + result += value[index]; + } + } + return result; + } + + function toWellFormedString(value, fallback = '') { + let string; + try { + string = String(value); + } catch { + string = String(fallback); + } + return typeof string.toWellFormed === 'function' + ? string.toWellFormed() + : manualToWellFormed(string); + } + + return Object.freeze({ toWellFormedString }); +}));