Files
Netcatty/domain/syncGuards.ts
2026-05-18 20:00:10 +08:00

91 lines
2.6 KiB
TypeScript

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:
| 'hosts'
| 'keys'
| 'identities'
| 'proxyProfiles'
| 'snippets'
| 'customGroups'
| 'snippetPackages'
| 'portForwardingRules'
| 'groupConfigs';
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;
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 };
}