Dropped storage keys should not cause upload

This commit is contained in:
Fedor Indutny 2022-02-14 11:36:32 -08:00 committed by GitHub
parent 67209d8881
commit a0b05f41e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 127 additions and 83 deletions

View file

@ -41,6 +41,11 @@ import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import type {
ExtendedStorageID,
RemoteRecord,
UnknownRecord,
} from '../types/StorageService.d';
type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier; type IManifestRecordIdentifier = Proto.ManifestRecord.IIdentifier;
@ -80,23 +85,10 @@ function redactStorageID(
return `${version ?? '?'}:${storageID.substring(0, 3)}${convoId}`; return `${version ?? '?'}:${storageID.substring(0, 3)}${convoId}`;
} }
type RemoteRecord = { function redactExtendedStorageID({
itemType: number;
storageID: string;
storageVersion?: number;
};
type UnknownRecord = RemoteRecord;
type ProcessRemoteRecordsResultType = Readonly<{
conflictCount: number;
deleteKeys: Array<string>;
}>;
function unknownRecordToRedactedID({
storageID, storageID,
storageVersion, storageVersion,
}: UnknownRecord): string { }: ExtendedStorageID): string {
return redactStorageID(storageID, storageVersion); return redactStorageID(storageID, storageVersion);
} }
@ -148,12 +140,11 @@ type GeneratedManifestType = {
async function generateManifest( async function generateManifest(
version: number, version: number,
previousManifest?: Proto.IManifestRecord, previousManifest?: Proto.IManifestRecord,
isNewManifest = false, isNewManifest = false
extraDeleteKeys = new Array<string>()
): Promise<GeneratedManifestType> { ): Promise<GeneratedManifestType> {
log.info( log.info(
`storageService.upload(${version}): generating manifest ` + `storageService.upload(${version}): generating manifest ` +
`new=${isNewManifest} extraDeleteKeys=${extraDeleteKeys.length}` `new=${isNewManifest}`
); );
await window.ConversationController.checkForConflicts(); await window.ConversationController.checkForConflicts();
@ -166,10 +157,6 @@ async function generateManifest(
const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set(); const manifestRecordKeys: Set<IManifestRecordIdentifier> = new Set();
const newItems: Set<Proto.IStorageItem> = new Set(); const newItems: Set<Proto.IStorageItem> = new Set();
for (const key of extraDeleteKeys) {
deleteKeys.push(Bytes.fromBase64(key));
}
const conversations = window.getConversations(); const conversations = window.getConversations();
for (let i = 0; i < conversations.length; i += 1) { for (let i = 0; i < conversations.length; i += 1) {
const conversation = conversations.models[i]; const conversation = conversations.models[i];
@ -300,7 +287,7 @@ async function generateManifest(
window.storage.get('storage-service-unknown-records') || [] window.storage.get('storage-service-unknown-records') || []
).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType)); ).filter((record: UnknownRecord) => !validRecordTypes.has(record.itemType));
const redactedUnknowns = unknownRecordsArray.map(unknownRecordToRedactedID); const redactedUnknowns = unknownRecordsArray.map(redactExtendedStorageID);
log.info( log.info(
`storageService.upload(${version}): adding unknown ` + `storageService.upload(${version}): adding unknown ` +
@ -322,7 +309,7 @@ async function generateManifest(
'storage-service-error-records', 'storage-service-error-records',
new Array<UnknownRecord>() new Array<UnknownRecord>()
); );
const redactedErrors = recordsWithErrors.map(unknownRecordToRedactedID); const redactedErrors = recordsWithErrors.map(redactExtendedStorageID);
log.info( log.info(
`storageService.upload(${version}): adding error ` + `storageService.upload(${version}): adding error ` +
@ -339,6 +326,24 @@ async function generateManifest(
manifestRecordKeys.add(identifier); manifestRecordKeys.add(identifier);
}); });
// Delete keys that we wanted to drop during the processing of the manifest.
const storedPendingDeletes = window.storage.get(
'storage-service-pending-deletes',
[]
);
const redactedPendingDeletes = storedPendingDeletes.map(
redactExtendedStorageID
);
log.info(
`storageService.upload(${version}): ` +
`deleting extra keys=${JSON.stringify(redactedPendingDeletes)} ` +
`count=${redactedPendingDeletes.length}`
);
for (const { storageID } of storedPendingDeletes) {
deleteKeys.push(Bytes.fromBase64(storageID));
}
// Validate before writing // Validate before writing
const rawDuplicates = new Set(); const rawDuplicates = new Set();
@ -827,7 +832,7 @@ async function mergeRecord(
async function processManifest( async function processManifest(
manifest: Proto.IManifestRecord, manifest: Proto.IManifestRecord,
version: number version: number
): Promise<ProcessRemoteRecordsResultType> { ): Promise<number> {
if (!window.textsecure.messaging) { if (!window.textsecure.messaging) {
throw new Error('storageService.processManifest: We are offline!'); throw new Error('storageService.processManifest: We are offline!');
} }
@ -907,12 +912,8 @@ async function processManifest(
}); });
let conflictCount = 0; let conflictCount = 0;
let deleteKeys = new Array<string>();
if (remoteOnlyRecords.size) { if (remoteOnlyRecords.size) {
({ conflictCount, deleteKeys } = await processRemoteRecords( conflictCount = await processRemoteRecords(version, remoteOnlyRecords);
version,
remoteOnlyRecords
));
} }
// Post-merge, if our local records contain any storage IDs that were not // Post-merge, if our local records contain any storage IDs that were not
@ -940,17 +941,16 @@ async function processManifest(
}); });
log.info( log.info(
`storageService.process(${version}): conflictCount=${conflictCount} ` + `storageService.process(${version}): conflictCount=${conflictCount}`
`deleteKeys=${deleteKeys.length}`
); );
return { conflictCount, deleteKeys }; return conflictCount;
} }
async function processRemoteRecords( async function processRemoteRecords(
storageVersion: number, storageVersion: number,
remoteOnlyRecords: Map<string, RemoteRecord> remoteOnlyRecords: Map<string, RemoteRecord>
): Promise<ProcessRemoteRecordsResultType> { ): Promise<number> {
const storageKeyBase64 = window.storage.get('storageKey'); const storageKeyBase64 = window.storage.get('storageKey');
if (!storageKeyBase64) { if (!storageKeyBase64) {
throw new Error('No storage key'); throw new Error('No storage key');
@ -1102,7 +1102,7 @@ async function processRemoteRecords(
}); });
} }
if (mergedRecord.hasConflict || mergedRecord.shouldDrop) { if (mergedRecord.hasConflict) {
conflictCount += 1; conflictCount += 1;
} }
@ -1116,7 +1116,7 @@ async function processRemoteRecords(
); );
log.info( log.info(
`storageService.process(${storageVersion}): ` + `storageService.process(${storageVersion}): ` +
`droppedKeys=${JSON.stringify(redactedDroppedKeys)} ` + `dropped keys=${JSON.stringify(redactedDroppedKeys)} ` +
`count=${redactedDroppedKeys.length}` `count=${redactedDroppedKeys.length}`
); );
@ -1124,9 +1124,7 @@ async function processRemoteRecords(
const newUnknownRecords = Array.from(unknownRecords.values()).filter( const newUnknownRecords = Array.from(unknownRecords.values()).filter(
(record: UnknownRecord) => !validRecordTypes.has(record.itemType) (record: UnknownRecord) => !validRecordTypes.has(record.itemType)
); );
const redactedNewUnknowns = newUnknownRecords.map( const redactedNewUnknowns = newUnknownRecords.map(redactExtendedStorageID);
unknownRecordToRedactedID
);
log.info( log.info(
`storageService.process(${storageVersion}): ` + `storageService.process(${storageVersion}): ` +
@ -1139,7 +1137,7 @@ async function processRemoteRecords(
); );
const redactedErrorRecords = newRecordsWithErrors.map( const redactedErrorRecords = newRecordsWithErrors.map(
unknownRecordToRedactedID redactExtendedStorageID
); );
log.info( log.info(
`storageService.process(${storageVersion}): ` + `storageService.process(${storageVersion}): ` +
@ -1154,14 +1152,25 @@ async function processRemoteRecords(
newRecordsWithErrors newRecordsWithErrors
); );
// Store/overwrite keys pending deletion, but use them only when we have to
// upload a new manifest to avoid oscillation.
const pendingDeletes = [...missingKeys, ...droppedKeys].map(storageID => ({
storageID,
storageVersion,
}));
const redactedPendingDeletes = pendingDeletes.map(redactExtendedStorageID);
log.info(
`storageService.process(${storageVersion}): ` +
`pending deletes=${JSON.stringify(redactedPendingDeletes)} ` +
`count=${redactedPendingDeletes.length}`
);
await window.storage.put('storage-service-pending-deletes', pendingDeletes);
if (conflictCount === 0) { if (conflictCount === 0) {
conflictBackOff.reset(); conflictBackOff.reset();
} }
return { return conflictCount;
conflictCount,
deleteKeys: [...missingKeys, ...droppedKeys],
};
} catch (err) { } catch (err) {
log.error( log.error(
`storageService.process(${storageVersion}): ` + `storageService.process(${storageVersion}): ` +
@ -1170,7 +1179,8 @@ async function processRemoteRecords(
); );
} }
return { conflictCount: 0, deleteKeys: [] }; // conflictCount
return 0;
} }
async function sync( async function sync(
@ -1218,21 +1228,18 @@ async function sync(
`version=${localManifestVersion}` `version=${localManifestVersion}`
); );
const { conflictCount, deleteKeys } = await processManifest( const conflictCount = await processManifest(manifest, version);
manifest,
version
);
log.info( log.info(
`storageService.sync: updated to version=${version} ` + `storageService.sync: updated to version=${version} ` +
`conflicts=${conflictCount} deleteKeys=${deleteKeys.length}` `conflicts=${conflictCount}`
); );
await window.storage.put('manifestVersion', version); await window.storage.put('manifestVersion', version);
const hasConflicts = conflictCount !== 0 || deleteKeys.length !== 0; const hasConflicts = conflictCount !== 0;
if (hasConflicts && !ignoreConflicts) { if (hasConflicts && !ignoreConflicts) {
await upload(true, deleteKeys); await upload(true);
} }
// We now know that we've successfully completed a storage service fetch // We now know that we've successfully completed a storage service fetch
@ -1249,10 +1256,7 @@ async function sync(
return manifest; return manifest;
} }
async function upload( async function upload(fromSync = false): Promise<void> {
fromSync = false,
extraDeleteKeys = new Array<string>()
): Promise<void> {
if (!window.textsecure.messaging) { if (!window.textsecure.messaging) {
throw new Error('storageService.upload: We are offline!'); throw new Error('storageService.upload: We are offline!');
} }
@ -1322,8 +1326,7 @@ async function upload(
const generatedManifest = await generateManifest( const generatedManifest = await generateManifest(
version, version,
previousManifest, previousManifest,
false, false
extraDeleteKeys
); );
await uploadManifest(version, generatedManifest); await uploadManifest(version, generatedManifest);
} catch (err) { } catch (err) {

View file

@ -558,7 +558,9 @@ export async function mergeGroupV1Record(
details.push('GV1 record for GV2 group, dropping'); details.push('GV1 record for GV2 group, dropping');
return { return {
hasConflict: true, // Note: conflicts cause immediate uploads, but we should upload
// only in response to user's action.
hasConflict: false,
shouldDrop: true, shouldDrop: true,
conversation, conversation,
oldStorageID, oldStorageID,
@ -771,11 +773,11 @@ export async function mergeContactRecord(
// All contacts must have UUID // All contacts must have UUID
if (!uuid) { if (!uuid) {
return { hasConflict: false, details: ['no uuid'] }; return { hasConflict: false, shouldDrop: true, details: ['no uuid'] };
} }
if (!isValidUuid(uuid)) { if (!isValidUuid(uuid)) {
return { hasConflict: false, details: ['invalid uuid'] }; return { hasConflict: false, shouldDrop: true, details: ['invalid uuid'] };
} }
const id = window.ConversationController.ensureContactIds({ const id = window.ConversationController.ensureContactIds({

View file

@ -3,19 +3,36 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import * as sinon from 'sinon';
import type { LoggerType } from '../../types/Logging';
import { normalizeUuid } from '../../util/normalizeUuid'; import { normalizeUuid } from '../../util/normalizeUuid';
describe('normalizeUuid', () => { describe('normalizeUuid', () => {
it('converts uuid to lower case', () => { let warn: sinon.SinonStub;
const uuid = generateUuid(); let logger: Pick<LoggerType, 'warn'>;
assert.strictEqual(normalizeUuid(uuid, 'context 1'), uuid);
assert.strictEqual(normalizeUuid(uuid.toUpperCase(), 'context 2'), uuid); beforeEach(() => {
warn = sinon.stub();
logger = { warn };
}); });
it("throws if passed a string that's not a UUID", () => { it('converts uuid to lower case', () => {
assert.throws( const uuid = generateUuid();
() => normalizeUuid('not-UUID-at-all', 'context 3'), assert.strictEqual(normalizeUuid(uuid, 'context 1', logger), uuid);
assert.strictEqual(
normalizeUuid(uuid.toUpperCase(), 'context 2', logger),
uuid
);
sinon.assert.notCalled(warn);
});
it("warns if passed a string that's not a UUID", () => {
normalizeUuid('not-UUID-at-all', 'context 3', logger);
sinon.assert.calledOnce(warn);
sinon.assert.calledWith(
warn,
'Normalizing invalid uuid: not-UUID-at-all to not-uuid-at-all in ' + 'Normalizing invalid uuid: not-UUID-at-all to not-uuid-at-all in ' +
'context "context 3"' 'context "context 3"'
); );

18
ts/types/Storage.d.ts vendored
View file

@ -11,7 +11,12 @@ import type { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverabil
import type { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import type { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
import type { RetryItemType } from '../util/retryPlaceholders'; import type { RetryItemType } from '../util/retryPlaceholders';
import type { ConfigMapType as RemoteConfigType } from '../RemoteConfig'; import type { ConfigMapType as RemoteConfigType } from '../RemoteConfig';
import { SystemTraySetting } from './SystemTraySetting'; import type { SystemTraySetting } from './SystemTraySetting';
import type {
ExtendedStorageID,
RemoteRecord,
UnknownRecord,
} from './StorageService';
import type { GroupCredentialType } from '../textsecure/WebAPI'; import type { GroupCredentialType } from '../textsecure/WebAPI';
import type { import type {
@ -105,14 +110,9 @@ export type StorageAccessType = {
avatarUrl: string; avatarUrl: string;
manifestVersion: number; manifestVersion: number;
storageCredentials: StorageServiceCredentials; storageCredentials: StorageServiceCredentials;
'storage-service-error-records': Array<{ 'storage-service-error-records': Array<UnknownRecord>;
itemType: number; 'storage-service-unknown-records': Array<UnknownRecord>;
storageID: string; 'storage-service-pending-deletes': Array<ExtendedStorageID>;
}>;
'storage-service-unknown-records': Array<{
itemType: number;
storageID: string;
}>;
'preferred-video-input-device': string; 'preferred-video-input-device': string;
'preferred-audio-input-device': AudioDevice; 'preferred-audio-input-device': AudioDevice;
'preferred-audio-output-device': AudioDevice; 'preferred-audio-output-device': AudioDevice;

13
ts/types/StorageService.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// Copyright 2020-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export type ExtendedStorageID = {
storageID: string;
storageVersion?: number;
};
export type RemoteRecord = ExtendedStorageID & {
itemType: number;
};
export type UnknownRecord = RemoteRecord;

View file

@ -3,15 +3,24 @@
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import { isValidUuid } from '../types/UUID'; import { isValidUuid } from '../types/UUID';
import { assert } from './assert'; import type { LoggerType } from '../types/Logging';
import * as log from '../logging/log';
export function normalizeUuid(uuid: string, context: string): UUIDStringType { export function normalizeUuid(
uuid: string,
context: string,
logger: Pick<LoggerType, 'warn'> = log
): UUIDStringType {
const result = uuid.toLowerCase(); const result = uuid.toLowerCase();
assert( if (!isValidUuid(uuid) || !isValidUuid(result)) {
isValidUuid(uuid) && isValidUuid(result), logger.warn(
`Normalizing invalid uuid: ${uuid} to ${result} in context "${context}"` `Normalizing invalid uuid: ${uuid} to ${result} in context "${context}"`
); );
// Cast anyway we don't want to throw here
return result as unknown as UUIDStringType;
}
return result; return result;
} }