Files
NetMesh/domain/syncGuards.ts
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

104 lines
3.2 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: 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 };
}