Files
NetMesh/domain/syncGuards.ts

104 lines
3.2 KiB
TypeScript
Raw Permalink Normal View History

import {
CLOUD_SYNC_PAYLOAD_ENTITY_KEYS,
type CloudSyncPayloadEntityKey,
type SyncPayload,
} from './sync';
export type ShrinkFinding =
| { suspicious: false }
| {
suspicious: true;
reason: 'bulk-shrink' | 'large-shrink';
entityType: CloudSyncPayloadEntityKey;
baseCount: number;
outgoingCount: number;
lost: number;
/** True when the comparison reference was the current remote (base was null). */
viaRemote?: boolean;
};
const CHECKED_ENTITIES = CLOUD_SYNC_PAYLOAD_ENTITY_KEYS;
type CheckedEntityType = CloudSyncPayloadEntityKey;
const BULK_SHRINK_RATIO = 0.5;
const BULK_SHRINK_MIN_ABSOLUTE = 3;
const LARGE_SHRINK_ABSOLUTE = 10;
function countOf(p: SyncPayload, key: CheckedEntityType): number {
const v = p[key];
return Array.isArray(v) ? v.length : 0;
}
export function detectSuspiciousShrink(
outgoing: SyncPayload,
base: SyncPayload | null,
remote?: SyncPayload | null,
): ShrinkFinding {
// Fall back to the current remote when we have no stored base — a null base
// happens on first sync, after unlock key re-derivation, or when the base
// blob failed to decrypt. Without this fallback, a degraded/empty local
// payload would be admitted unconditionally and could overwrite populated
// remote data (#779). We only use `remote` when `base` is unavailable so
// legitimate resurrections (device that legitimately grew past an older
// remote snapshot) remain unaffected.
const reference = base ?? remote ?? null;
const viaRemote = !base && !!remote;
if (!reference) return { suspicious: false };
for (const entityType of CHECKED_ENTITIES) {
const baseCount = countOf(reference, entityType);
const outgoingCount = countOf(outgoing, entityType);
const lost = baseCount - outgoingCount;
if (lost <= 0) continue;
// groupConfigs often hold startup commands / host appearance for only one
// or two folders. A hydration race that uploads hosts + groupConfigs:[]
// falls below the bulk/ratio gates (#2757). Only flag this entity — wiping
// the last snippet/note/etc. while hosts remain stays a normal delete.
// Fully empty outgoing snapshots remain allowed as real deletions.
if (
entityType === 'groupConfigs'
&& outgoingCount === 0
&& baseCount > 0
&& CHECKED_ENTITIES.some((other) => other !== entityType && countOf(outgoing, other) > 0)
) {
return {
suspicious: true,
reason: lost >= LARGE_SHRINK_ABSOLUTE ? 'large-shrink' : 'bulk-shrink',
entityType,
baseCount,
outgoingCount,
lost,
...(viaRemote ? { viaRemote: true } : {}),
};
}
if (lost >= LARGE_SHRINK_ABSOLUTE) {
return {
suspicious: true,
reason: 'large-shrink',
entityType,
baseCount,
outgoingCount,
lost,
...(viaRemote ? { viaRemote: true } : {}),
};
}
if (baseCount > 0 && lost / baseCount >= BULK_SHRINK_RATIO && lost >= BULK_SHRINK_MIN_ABSOLUTE) {
return {
suspicious: true,
reason: 'bulk-shrink',
entityType,
baseCount,
outgoingCount,
lost,
...(viaRemote ? { viaRemote: true } : {}),
};
}
}
return { suspicious: false };
}