diff --git a/keyserver/src/shared/state-sync/current-user-state-sync-spec.js b/keyserver/src/shared/state-sync/current-user-state-sync-spec.js index 3d29ff899..813fc55ef 100644 --- a/keyserver/src/shared/state-sync/current-user-state-sync-spec.js +++ b/keyserver/src/shared/state-sync/current-user-state-sync-spec.js @@ -1,23 +1,38 @@ // @flow import { currentUserStateSyncSpec as libSpec } from 'lib/shared/state-sync/current-user-state-sync-spec.js'; import type { CurrentUserInfo } from 'lib/types/user-types.js'; +import { currentUserInfoValidator } from 'lib/types/user-types.js'; +import { hash } from 'lib/utils/objects.js'; import type { ServerStateSyncSpec } from './state-sync-spec.js'; import { fetchCurrentUserInfo } from '../../fetchers/user-fetchers.js'; import type { Viewer } from '../../session/viewer.js'; +import { validateOutput } from '../../utils/validation-utils.js'; export const currentUserStateSyncSpec: ServerStateSyncSpec< CurrentUserInfo, CurrentUserInfo, CurrentUserInfo, void, > = Object.freeze({ - fetch(viewer: Viewer) { - return fetchCurrentUserInfo(viewer); - }, + fetch, fetchFullSocketSyncPayload(viewer: Viewer) { return fetchCurrentUserInfo(viewer); }, + async fetchServerInfosHash(viewer: Viewer) { + const info = await fetch(viewer); + return getHash(info); + }, + getServerInfosHash: getHash, + getServerInfoHash: getHash, ...libSpec, }); + +function fetch(viewer: Viewer) { + return fetchCurrentUserInfo(viewer); +} + +function getHash(currentUserInfo: CurrentUserInfo) { + return hash(validateOutput(null, currentUserInfoValidator, currentUserInfo)); +} diff --git a/keyserver/src/shared/state-sync/entries-state-sync-spec.js b/keyserver/src/shared/state-sync/entries-state-sync-spec.js index 5e9219a37..8a3436081 100644 --- a/keyserver/src/shared/state-sync/entries-state-sync-spec.js +++ b/keyserver/src/shared/state-sync/entries-state-sync-spec.js @@ -1,41 +1,60 @@ // @flow import { serverEntryInfosObject } from 'lib/shared/entry-utils.js'; import { entriesStateSyncSpec as libSpec } from 'lib/shared/state-sync/entries-state-sync-spec.js'; -import type { - CalendarQuery, - RawEntryInfo, - RawEntryInfos, +import { + type CalendarQuery, + type RawEntryInfo, + type RawEntryInfos, + rawEntryInfoValidator, } from 'lib/types/entry-types.js'; import type { ClientEntryInconsistencyReportCreationRequest } from 'lib/types/report-types.js'; +import { hash, combineUnorderedHashes, values } from 'lib/utils/objects.js'; import type { ServerStateSyncSpec } from './state-sync-spec.js'; import { fetchEntryInfos, fetchEntryInfosByID, } from '../../fetchers/entry-fetchers.js'; import type { Viewer } from '../../session/viewer.js'; +import { validateOutput } from '../../utils/validation-utils.js'; export const entriesStateSyncSpec: ServerStateSyncSpec< RawEntryInfos, $ReadOnlyArray, RawEntryInfo, $ReadOnlyArray, > = Object.freeze({ - async fetch(viewer: Viewer, ids?: $ReadOnlySet) { - if (ids) { - return fetchEntryInfosByID(viewer, ids); - } - const query = [viewer.calendarQuery]; - const entriesResult = await fetchEntryInfos(viewer, query); - return serverEntryInfosObject(entriesResult.rawEntryInfos); - }, + fetch, async fetchFullSocketSyncPayload( viewer: Viewer, query: $ReadOnlyArray, ) { const result = await fetchEntryInfos(viewer, query); return result.rawEntryInfos; }, + async fetchServerInfosHash(viewer: Viewer, ids?: $ReadOnlySet) { + const info = await fetch(viewer, ids); + return getServerInfosHash(info); + }, + getServerInfosHash, + getServerInfoHash, ...libSpec, }); + +async function fetch(viewer: Viewer, ids?: $ReadOnlySet) { + if (ids) { + return fetchEntryInfosByID(viewer, ids); + } + const query = [viewer.calendarQuery]; + const entriesResult = await fetchEntryInfos(viewer, query); + return serverEntryInfosObject(entriesResult.rawEntryInfos); +} + +function getServerInfosHash(infos: RawEntryInfos) { + return combineUnorderedHashes(values(infos).map(getServerInfoHash)); +} + +function getServerInfoHash(info: RawEntryInfo) { + return hash(validateOutput(null, rawEntryInfoValidator, info)); +} diff --git a/keyserver/src/shared/state-sync/state-sync-spec.js b/keyserver/src/shared/state-sync/state-sync-spec.js index 00f2fbb50..af0ce2024 100644 --- a/keyserver/src/shared/state-sync/state-sync-spec.js +++ b/keyserver/src/shared/state-sync/state-sync-spec.js @@ -1,20 +1,26 @@ // @flow import type { StateSyncSpec } from 'lib/shared/state-sync/state-sync-spec.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { Viewer } from '../../session/viewer.js'; export type ServerStateSyncSpec< Infos, FullSocketSyncPayload, Info, Inconsistencies, > = { +fetch: (viewer: Viewer, ids?: $ReadOnlySet) => Promise, +fetchFullSocketSyncPayload: ( viewer: Viewer, calendarQuery: $ReadOnlyArray, ) => Promise, + +fetchServerInfosHash: ( + viewer: Viewer, + ids?: $ReadOnlySet, + ) => Promise, + +getServerInfosHash: (infos: Infos) => number, + +getServerInfoHash: (info: Info) => number, ...StateSyncSpec, }; diff --git a/keyserver/src/shared/state-sync/threads-state-sync-spec.js b/keyserver/src/shared/state-sync/threads-state-sync-spec.js index 3f4d8eeac..16605ae29 100644 --- a/keyserver/src/shared/state-sync/threads-state-sync-spec.js +++ b/keyserver/src/shared/state-sync/threads-state-sync-spec.js @@ -1,30 +1,49 @@ // @flow import { threadsStateSyncSpec as libSpec } from 'lib/shared/state-sync/threads-state-sync-spec.js'; import type { ClientThreadInconsistencyReportCreationRequest } from 'lib/types/report-types.js'; import { type RawThreadInfos, type RawThreadInfo, + rawThreadInfoValidator, } from 'lib/types/thread-types.js'; +import { hash, combineUnorderedHashes, values } from 'lib/utils/objects.js'; import type { ServerStateSyncSpec } from './state-sync-spec.js'; import { fetchThreadInfos } from '../../fetchers/thread-fetchers.js'; import type { Viewer } from '../../session/viewer.js'; +import { validateOutput } from '../../utils/validation-utils.js'; export const threadsStateSyncSpec: ServerStateSyncSpec< RawThreadInfos, RawThreadInfos, RawThreadInfo, $ReadOnlyArray, > = Object.freeze({ - async fetch(viewer: Viewer, ids?: $ReadOnlySet) { - const filter = ids ? { threadIDs: ids } : undefined; - const result = await fetchThreadInfos(viewer, filter); - return result.threadInfos; - }, + fetch, async fetchFullSocketSyncPayload(viewer: Viewer) { const result = await fetchThreadInfos(viewer); return result.threadInfos; }, + async fetchServerInfosHash(viewer: Viewer, ids?: $ReadOnlySet) { + const infos = await fetch(viewer, ids); + return getServerInfosHash(infos); + }, + getServerInfosHash, + getServerInfoHash, ...libSpec, }); + +async function fetch(viewer: Viewer, ids?: $ReadOnlySet) { + const filter = ids ? { threadIDs: ids } : undefined; + const result = await fetchThreadInfos(viewer, filter); + return result.threadInfos; +} + +function getServerInfosHash(infos: RawThreadInfos) { + return combineUnorderedHashes(values(infos).map(getServerInfoHash)); +} + +function getServerInfoHash(info: RawThreadInfo) { + return hash(validateOutput(null, rawThreadInfoValidator, info)); +} diff --git a/keyserver/src/shared/state-sync/users-state-sync-spec.js b/keyserver/src/shared/state-sync/users-state-sync-spec.js index 661380bcb..54c4465fa 100644 --- a/keyserver/src/shared/state-sync/users-state-sync-spec.js +++ b/keyserver/src/shared/state-sync/users-state-sync-spec.js @@ -1,30 +1,48 @@ // @flow import { usersStateSyncSpec as libSpec } from 'lib/shared/state-sync/users-state-sync-spec.js'; import type { UserInconsistencyReportCreationRequest } from 'lib/types/report-types.js'; import type { UserInfos, UserInfo } from 'lib/types/user-types.js'; -import { values } from 'lib/utils/objects.js'; +import { userInfoValidator } from 'lib/types/user-types.js'; +import { values, hash, combineUnorderedHashes } from 'lib/utils/objects.js'; import type { ServerStateSyncSpec } from './state-sync-spec.js'; import { fetchKnownUserInfos } from '../../fetchers/user-fetchers.js'; import type { Viewer } from '../../session/viewer.js'; +import { validateOutput } from '../../utils/validation-utils.js'; export const usersStateSyncSpec: ServerStateSyncSpec< UserInfos, $ReadOnlyArray, UserInfo, $ReadOnlyArray, > = Object.freeze({ - fetch(viewer: Viewer, ids?: $ReadOnlySet) { - if (ids) { - return fetchKnownUserInfos(viewer, [...ids]); - } - - return fetchKnownUserInfos(viewer); - }, + fetch, async fetchFullSocketSyncPayload(viewer: Viewer) { const result = await fetchKnownUserInfos(viewer); return values(result); }, + async fetchServerInfosHash(viewer: Viewer, ids?: $ReadOnlySet) { + const infos = await fetch(viewer, ids); + return getServerInfosHash(infos); + }, + getServerInfosHash, + getServerInfoHash, ...libSpec, }); + +function fetch(viewer: Viewer, ids?: $ReadOnlySet) { + if (ids) { + return fetchKnownUserInfos(viewer, [...ids]); + } + + return fetchKnownUserInfos(viewer); +} + +function getServerInfosHash(infos: UserInfos) { + return combineUnorderedHashes(values(infos).map(getServerInfoHash)); +} + +function getServerInfoHash(info: UserInfo) { + return hash(validateOutput(null, userInfoValidator, info)); +} diff --git a/keyserver/src/socket/session-utils.js b/keyserver/src/socket/session-utils.js index 8b8dc33e5..e68a37a88 100644 --- a/keyserver/src/socket/session-utils.js +++ b/keyserver/src/socket/session-utils.js @@ -1,501 +1,525 @@ // @flow import invariant from 'invariant'; import t from 'tcomb'; import type { TUnion } from 'tcomb'; +import { + NEXT_CODE_VERSION, + hasMinCodeVersion, +} from 'lib/shared/version-utils.js'; import type { UpdateActivityResult } from 'lib/types/activity-types.js'; import type { IdentityKeysBlob } from 'lib/types/crypto-types.js'; import { isDeviceType } from 'lib/types/device-types.js'; import type { CalendarQuery, DeltaEntryInfosResponse, } from 'lib/types/entry-types.js'; import { reportTypes, type ThreadInconsistencyReportCreationRequest, type EntryInconsistencyReportCreationRequest, } from 'lib/types/report-types.js'; import { serverRequestTypes, type ThreadInconsistencyClientResponse, type EntryInconsistencyClientResponse, type ClientResponse, type ServerServerRequest, type ServerCheckStateServerRequest, } from 'lib/types/request-types.js'; import { sessionCheckFrequency } from 'lib/types/session-types.js'; import { signedIdentityKeysBlobValidator } from 'lib/utils/crypto-utils.js'; import { hash, values } from 'lib/utils/objects.js'; import { promiseAll } from 'lib/utils/promises.js'; import { tShape, tPlatform, tPlatformDetails, } from 'lib/utils/validation-utils.js'; import { createOlmSession } from '../creators/olm-session-creator.js'; import { saveOneTimeKeys } from '../creators/one-time-keys-creator.js'; import createReport from '../creators/report-creator.js'; import { fetchEntriesForSession } from '../fetchers/entry-fetchers.js'; import { checkIfSessionHasEnoughOneTimeKeys } from '../fetchers/key-fetchers.js'; import { activityUpdatesInputValidator } from '../responders/activity-responders.js'; import { handleAsyncPromise } from '../responders/handlers.js'; import { threadInconsistencyReportValidatorShape, entryInconsistencyReportValidatorShape, } from '../responders/report-responders.js'; import { setNewSession, setCookiePlatform, setCookiePlatformDetails, setCookieSignedIdentityKeysBlob, } from '../session/cookies.js'; import type { Viewer } from '../session/viewer.js'; import { serverStateSyncSpecs } from '../shared/state-sync/state-sync-specs.js'; import { activityUpdater } from '../updaters/activity-updaters.js'; import { compareNewCalendarQuery } from '../updaters/entry-updaters.js'; import type { SessionUpdate } from '../updaters/session-updaters.js'; import { getOlmUtility } from '../utils/olm-utils.js'; const clientResponseInputValidator: TUnion = t.union([ tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM', x => x === serverRequestTypes.PLATFORM, ), platform: tPlatform, }), tShape({ ...threadInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.THREAD_INCONSISTENCY', x => x === serverRequestTypes.THREAD_INCONSISTENCY, ), }), tShape({ ...entryInconsistencyReportValidatorShape, type: t.irreducible( 'serverRequestTypes.ENTRY_INCONSISTENCY', x => x === serverRequestTypes.ENTRY_INCONSISTENCY, ), }), tShape({ type: t.irreducible( 'serverRequestTypes.PLATFORM_DETAILS', x => x === serverRequestTypes.PLATFORM_DETAILS, ), platformDetails: tPlatformDetails, }), tShape({ type: t.irreducible( 'serverRequestTypes.CHECK_STATE', x => x === serverRequestTypes.CHECK_STATE, ), hashResults: t.dict(t.String, t.Boolean), }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_ACTIVITY_UPDATES', x => x === serverRequestTypes.INITIAL_ACTIVITY_UPDATES, ), activityUpdates: activityUpdatesInputValidator, }), tShape({ type: t.irreducible( 'serverRequestTypes.MORE_ONE_TIME_KEYS', x => x === serverRequestTypes.MORE_ONE_TIME_KEYS, ), keys: t.list(t.String), }), tShape({ type: t.irreducible( 'serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB', x => x === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB, ), signedIdentityKeysBlob: signedIdentityKeysBlobValidator, }), tShape({ type: t.irreducible( 'serverRequestTypes.INITIAL_NOTIFICATIONS_ENCRYPTED_MESSAGE', x => x === serverRequestTypes.INITIAL_NOTIFICATIONS_ENCRYPTED_MESSAGE, ), initialNotificationsEncryptedMessage: t.String, }), ]); type StateCheckStatus = | { status: 'state_validated' } | { status: 'state_invalid', invalidKeys: $ReadOnlyArray } | { status: 'state_check' }; type ProcessClientResponsesResult = { serverRequests: ServerServerRequest[], stateCheckStatus: ?StateCheckStatus, activityUpdateResult: ?UpdateActivityResult, }; async function processClientResponses( viewer: Viewer, clientResponses: $ReadOnlyArray, ): Promise { let viewerMissingPlatform = !viewer.platform; const { platformDetails } = viewer; let viewerMissingPlatformDetails = !platformDetails || (isDeviceType(viewer.platform) && (platformDetails.codeVersion === null || platformDetails.codeVersion === undefined || platformDetails.stateVersion === null || platformDetails.stateVersion === undefined)); const promises = []; let activityUpdates = []; let stateCheckStatus = null; const clientSentPlatformDetails = clientResponses.some( response => response.type === serverRequestTypes.PLATFORM_DETAILS, ); for (const clientResponse of clientResponses) { if ( clientResponse.type === serverRequestTypes.PLATFORM && !clientSentPlatformDetails ) { promises.push(setCookiePlatform(viewer, clientResponse.platform)); viewerMissingPlatform = false; if (!isDeviceType(clientResponse.platform)) { viewerMissingPlatformDetails = false; } } else if ( clientResponse.type === serverRequestTypes.THREAD_INCONSISTENCY ) { promises.push(recordThreadInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.ENTRY_INCONSISTENCY) { promises.push(recordEntryInconsistency(viewer, clientResponse)); } else if (clientResponse.type === serverRequestTypes.PLATFORM_DETAILS) { promises.push( setCookiePlatformDetails(viewer, clientResponse.platformDetails), ); viewerMissingPlatform = false; viewerMissingPlatformDetails = false; } else if ( clientResponse.type === serverRequestTypes.INITIAL_ACTIVITY_UPDATES ) { activityUpdates = [...activityUpdates, ...clientResponse.activityUpdates]; } else if (clientResponse.type === serverRequestTypes.CHECK_STATE) { const invalidKeys = []; for (const key in clientResponse.hashResults) { const result = clientResponse.hashResults[key]; if (!result) { invalidKeys.push(key); } } stateCheckStatus = invalidKeys.length > 0 ? { status: 'state_invalid', invalidKeys } : { status: 'state_validated' }; } else if (clientResponse.type === serverRequestTypes.MORE_ONE_TIME_KEYS) { invariant(clientResponse.keys, 'keys expected in client response'); handleAsyncPromise(saveOneTimeKeys(viewer, clientResponse.keys)); } else if ( clientResponse.type === serverRequestTypes.SIGNED_IDENTITY_KEYS_BLOB ) { invariant( clientResponse.signedIdentityKeysBlob, 'signedIdentityKeysBlob expected in client response', ); const { signedIdentityKeysBlob } = clientResponse; const identityKeys: IdentityKeysBlob = JSON.parse( signedIdentityKeysBlob.payload, ); const olmUtil = getOlmUtility(); try { olmUtil.ed25519_verify( identityKeys.primaryIdentityPublicKeys.ed25519, signedIdentityKeysBlob.payload, signedIdentityKeysBlob.signature, ); handleAsyncPromise( setCookieSignedIdentityKeysBlob( viewer.cookieID, signedIdentityKeysBlob, ), ); } catch (e) { continue; } } else if ( clientResponse.type === serverRequestTypes.INITIAL_NOTIFICATIONS_ENCRYPTED_MESSAGE ) { invariant( t.String.is(clientResponse.initialNotificationsEncryptedMessage), 'initialNotificationsEncryptedMessage expected in client response', ); const { initialNotificationsEncryptedMessage } = clientResponse; try { await createOlmSession( initialNotificationsEncryptedMessage, 'notifications', viewer.cookieID, ); } catch (e) { continue; } } } const activityUpdatePromise = (async () => { if (activityUpdates.length === 0) { return undefined; } return await activityUpdater(viewer, { updates: activityUpdates }); })(); const serverRequests = []; const checkOneTimeKeysPromise = (async () => { if (!viewer.loggedIn) { return; } const enoughOneTimeKeys = await checkIfSessionHasEnoughOneTimeKeys( viewer.session, ); if (!enoughOneTimeKeys) { serverRequests.push({ type: serverRequestTypes.MORE_ONE_TIME_KEYS }); } })(); const { activityUpdateResult } = await promiseAll({ all: Promise.all(promises), activityUpdateResult: activityUpdatePromise, checkOneTimeKeysPromise, }); if ( !stateCheckStatus && viewer.loggedIn && viewer.sessionLastValidated + sessionCheckFrequency < Date.now() ) { stateCheckStatus = { status: 'state_check' }; } if (viewerMissingPlatform) { serverRequests.push({ type: serverRequestTypes.PLATFORM }); } if (viewerMissingPlatformDetails) { serverRequests.push({ type: serverRequestTypes.PLATFORM_DETAILS }); } return { serverRequests, stateCheckStatus, activityUpdateResult }; } async function recordThreadInconsistency( viewer: Viewer, response: ThreadInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.THREAD_INCONSISTENCY, }: ThreadInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } async function recordEntryInconsistency( viewer: Viewer, response: EntryInconsistencyClientResponse, ): Promise { const { type, ...rest } = response; const reportCreationRequest = ({ ...rest, type: reportTypes.ENTRY_INCONSISTENCY, }: EntryInconsistencyReportCreationRequest); await createReport(viewer, reportCreationRequest); } type SessionInitializationResult = | { sessionContinued: false } | { sessionContinued: true, deltaEntryInfoResult: DeltaEntryInfosResponse, sessionUpdate: SessionUpdate, }; async function initializeSession( viewer: Viewer, calendarQuery: CalendarQuery, oldLastUpdate: number, ): Promise { if (!viewer.loggedIn) { return { sessionContinued: false }; } if (!viewer.hasSessionInfo) { // If the viewer has no session info but is logged in, that is indicative // of an expired / invalidated session and we should generate a new one await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } if (oldLastUpdate < viewer.sessionLastUpdated) { // If the client has an older last_update than the server is tracking for // that client, then the client either had some issue persisting its store, // or the user restored the client app from a backup. Either way, we should // invalidate the existing session, since the server has assumed that the // checkpoint is further along than it is on the client, and might not still // have all of the updates necessary to do an incremental update await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } let comparisonResult = null; try { comparisonResult = compareNewCalendarQuery(viewer, calendarQuery); } catch (e) { if (e.message !== 'unknown_error') { throw e; } } if (comparisonResult) { const { difference, oldCalendarQuery } = comparisonResult; const sessionUpdate = { ...comparisonResult.sessionUpdate, lastUpdate: oldLastUpdate, }; const deltaEntryInfoResult = await fetchEntriesForSession( viewer, difference, oldCalendarQuery, ); return { sessionContinued: true, deltaEntryInfoResult, sessionUpdate }; } else { await setNewSession(viewer, calendarQuery, oldLastUpdate); return { sessionContinued: false }; } } type StateCheckResult = { sessionUpdate?: SessionUpdate, checkStateRequest?: ServerCheckStateServerRequest, }; async function checkState( viewer: Viewer, status: StateCheckStatus, ): Promise { if (status.status === 'state_validated') { return { sessionUpdate: { lastValidated: Date.now() } }; } else if (status.status === 'state_check') { const promises = Object.fromEntries( values(serverStateSyncSpecs).map(spec => [ spec.hashKey, (async () => { - const data = await spec.fetch(viewer); - return hash(data); + if ( + !hasMinCodeVersion(viewer.platformDetails, { + native: NEXT_CODE_VERSION, + web: NEXT_CODE_VERSION, + }) + ) { + const data = await spec.fetch(viewer); + return hash(data); + } + const infosHash = await spec.fetchServerInfosHash(viewer); + return infosHash; })(), ]), ); const hashesToCheck = await promiseAll(promises); const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, }; return { checkStateRequest }; } const invalidKeys = new Set(status.invalidKeys); const shouldFetchAll = Object.fromEntries( values(serverStateSyncSpecs).map(spec => [ spec.hashKey, invalidKeys.has(spec.hashKey), ]), ); const idsToFetch = Object.fromEntries( values(serverStateSyncSpecs) .filter(spec => spec.innerHashSpec?.hashKey) .map(spec => [spec.innerHashSpec?.hashKey, new Set()]), ); for (const key of invalidKeys) { const [innerHashKey, id] = key.split('|'); if (innerHashKey && id) { idsToFetch[innerHashKey]?.add(id); } } const fetchPromises = {}; for (const spec of values(serverStateSyncSpecs)) { if (shouldFetchAll[spec.hashKey]) { fetchPromises[spec.hashKey] = spec.fetch(viewer); } else if (idsToFetch[spec.innerHashSpec?.hashKey]?.size > 0) { fetchPromises[spec.hashKey] = spec.fetch( viewer, idsToFetch[spec.innerHashSpec?.hashKey], ); } } const fetchedData = await promiseAll(fetchPromises); const specPerHashKey = Object.fromEntries( values(serverStateSyncSpecs).map(spec => [spec.hashKey, spec]), ); const specPerInnerHashKey = Object.fromEntries( values(serverStateSyncSpecs) .filter(spec => spec.innerHashSpec?.hashKey) .map(spec => [spec.innerHashSpec?.hashKey, spec]), ); const hashesToCheck = {}, failUnmentioned = {}, stateChanges = {}; for (const key of invalidKeys) { const spec = specPerHashKey[key]; const innerHashKey = spec?.innerHashSpec?.hashKey; const isTopLevelKey = !!spec; if (isTopLevelKey && innerHashKey) { // Instead of returning all the infos, we want to narrow down and figure // out which infos don't match first const infos = fetchedData[key]; for (const infoID in infos) { - hashesToCheck[`${innerHashKey}|${infoID}`] = hash(infos[infoID]); + let hashValue; + if ( + hasMinCodeVersion(viewer.platformDetails, { + native: NEXT_CODE_VERSION, + web: NEXT_CODE_VERSION, + }) + ) { + hashValue = spec.getServerInfoHash(infos[infoID]); + } else { + hashValue = hash(infos[infoID]); + } + hashesToCheck[`${innerHashKey}|${infoID}`] = hashValue; } failUnmentioned[key] = true; } else if (isTopLevelKey) { stateChanges[key] = fetchedData[key]; } else { const [keyPrefix, id] = key.split('|'); const innerSpec = specPerInnerHashKey[keyPrefix]; const innerHashSpec = innerSpec?.innerHashSpec; if (!innerHashSpec || !id) { continue; } const infos = fetchedData[innerSpec.hashKey]; const info = infos[id]; if (!info || innerHashSpec.additionalDeleteCondition?.(info)) { if (!stateChanges[innerHashSpec.deleteKey]) { stateChanges[innerHashSpec.deleteKey] = []; } stateChanges[innerHashSpec.deleteKey].push(id); continue; } if (!stateChanges[innerHashSpec.rawInfosKey]) { stateChanges[innerHashSpec.rawInfosKey] = []; } stateChanges[innerHashSpec.rawInfosKey].push(info); } } const checkStateRequest = { type: serverRequestTypes.CHECK_STATE, hashesToCheck, failUnmentioned, stateChanges, }; if (Object.keys(hashesToCheck).length === 0) { return { checkStateRequest, sessionUpdate: { lastValidated: Date.now() } }; } else { return { checkStateRequest }; } } export { clientResponseInputValidator, processClientResponses, initializeSession, checkState, }; diff --git a/lib/utils/objects.js b/lib/utils/objects.js index d0b521fe6..4bc05d788 100644 --- a/lib/utils/objects.js +++ b/lib/utils/objects.js @@ -1,131 +1,140 @@ // @flow import stableStringify from 'fast-json-stable-stringify'; import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual.js'; import _isPlainObject from 'lodash/fp/isPlainObject.js'; import stringHash from 'string-hash'; type ObjectMap = { +[key: K]: T }; type NestedObjectMap = { +[key: K]: T | NestedObjectMap }; function findMaximumDepth(obj: Object): ?{ path: string, depth: number } { let longestPath = null; let longestDepth = null; for (const key in obj) { const value = obj[key]; if (typeof value !== 'object' || !value) { if (!longestDepth) { longestPath = key; longestDepth = 1; } continue; } const childResult = findMaximumDepth(obj[key]); if (!childResult) { continue; } const { path, depth } = childResult; const ourDepth = depth + 1; if (longestDepth === null || ourDepth > longestDepth) { longestPath = `${key}.${path}`; longestDepth = ourDepth; } } if (!longestPath || !longestDepth) { return null; } return { path: longestPath, depth: longestDepth }; } function values(map: ObjectMap): T[] { return Object.values ? // https://github.com/facebook/flow/issues/2221 // $FlowFixMe - Object.values currently does not have good flow support Object.values(map) : Object.keys(map).map((key: K): T => map[key]); } function keys(map: ObjectMap): K[] { return Object.keys(map); } function entries(map: ObjectMap): [K, T][] { // $FlowFixMe - flow treats the values as mixed, but we know that they are T return Object.entries(map); } function assignValueWithKey( obj: NestedObjectMap, key: K, value: T | NestedObjectMap, ): NestedObjectMap { return { ...obj, ...Object.fromEntries([[key, value]]), }; } function hash(obj: ?Object): number { if (!obj) { return -1; } return stringHash(stableStringify(obj)); } +// This function doesn't look at the order of the hashes inside of the array +// e.g `combineUnorderedHashes([1,2,3]) === combineUnorderedHashes([3,1,2])` +// so it should only be used if the hashes include their ordering in them +// somehow (e.g. `RawThreadInfo` contains `id`) +function combineUnorderedHashes(hashes: $ReadOnlyArray): number { + return hashes.reduce((a, v) => a ^ v, 0); +} + // returns an object with properties from obj1 not included in obj2 function deepDiff( obj1: NestedObjectMap, obj2: NestedObjectMap, ): NestedObjectMap { let diff: NestedObjectMap = {}; keys(obj1).forEach((key: K) => { if (_isEqual(obj1[key], obj2[key])) { return; } if (!_isPlainObject(obj1[key]) || !_isPlainObject(obj2[key])) { diff = assignValueWithKey(diff, key, obj1[key]); return; } const nestedObj1: ObjectMap = (obj1[key]: any); const nestedObj2: ObjectMap = (obj2[key]: any); const nestedDiff = deepDiff(nestedObj1, nestedObj2); if (Object.keys(nestedDiff).length > 0) { diff = assignValueWithKey(diff, key, nestedDiff); } }); return diff; } function assertObjectsAreEqual( processedObject: ObjectMap, expectedObject: ObjectMap, message: string, ) { if (_isEqual(processedObject)(expectedObject)) { return; } const dataProcessedButNotExpected = deepDiff(processedObject, expectedObject); const dataExpectedButNotProcessed = deepDiff(expectedObject, processedObject); invariant( false, `${message}: Objects should be equal.` + ` Data processed but not expected:` + ` ${JSON.stringify(dataProcessedButNotExpected)}` + ` Data expected but not processed:` + ` ${JSON.stringify(dataExpectedButNotProcessed)}`, ); } export { findMaximumDepth, values, hash, + combineUnorderedHashes, assertObjectsAreEqual, deepDiff, entries, };