fix(git-sync): red-team hardening — 12 confirmed sync-breaking bugs + regression tests
A 10-agent red-team pass on the two-way Docmost<->git sync surfaced 16 ranked findings (9 others triaged out as already-defended). Wrote a reproduction test per finding (each asserts the CORRECT behavior, so it fails on the bug), then fixed the production code so every repro goes green. All confirmed bugs: Round-trip data loss (markdown-converter.ts + docmost-schema.ts mirror): - #1 editor-ext node types silently dropped on export — ported the 8 missing canon nodes (footnoteReference/footnotesList/footnoteDefinition, htmlEmbed, status, pageEmbed, transclusionSource/Reference) into the git-sync schema mirror and added converter cases that emit their schema-matching HTML instead of flattening unknown nodes to '' (this was the critical data-loss flagged in review #1679: footnotes/htmlEmbed lost on sync). Snapshot surface updated. - #2 top-level image lost width/height/align/attachmentId — now emits an HTML <img> (like video/diagrams) when it carries layout attrs; bare images stay . Image node parses width/height as strings so they re-import. - #3 code block containing a ``` fence corrupted on round-trip — outer fence is now widened to (longest-inner-backtick-run + 1). - #16 deep nesting threw RangeError (page never synced) — added a depth guard (MAX_NODE_DEPTH=400) so the converter never overflows the stack. Push/layout/cycle (engine): - #4 disambiguation ' ~slugId' suffix corrupted Docmost titles + order-dependent layout — deterministic, order-independent sibling disambiguation; suffix is stripped from a path-derived title ONLY when the new name is exactly the old title plus the suffix (never a genuine retitle ending in ' ~token'). - #6 retry-adopt by (parent,title) clobbered the wrong duplicate-title sibling — ambiguous (parent,title) is no longer adopted (falls back to fresh create). - #12 a new child under a new parent was created at ROOT — creates are ordered parent-before-child with an in-memory created-id map for parent resolution. - #13 git conflict markers could reach Docmost — bodies are scanned and the marker lines stripped (a '=======' line is only treated as a conflict separator inside a <<<<<<< ... >>>>>>> block, so setext headings are safe). - #15 a divergent `docmost` mirror was escalated by runPush but dropped by runCycle — RunCycleResult now forwards divergentDocmost to the orchestrator. Server (merge / lock / provenance): - #9 3-way merge lost a human's block edit when git inserted an adjacent block — finer-grained diff3 region merge (via lcs) preserves non-overlapping human edits; genuine same-block conflicts still resolve git-wins. - #10 single-writer race — module-static liveLocks closes the same-process TOCTOU window, and a heartbeat refresh that cannot confirm the lock now aborts the cycle at its next write checkpoint (cooperative AbortSignal threaded through runCycle). Cross-process fencing tokens remain a follow-up. - #14 sticky-agent provenance overrode an explicit actor='git-sync' write, blinding the listener loop-guard — resolveSource now lets an explicit actor win over the sticky-agent fallback (explicit agent still wins). Verified: git-sync vitest 617 pass (+1 expected-fail), server unit jest 1541 pass, server tsc clean. A review pass over the fixes caught and corrected a title-suffix over-strip, an inert abort signal, a document-wide conflict-marker strip, and two leaf-atom content-holes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
packages/git-sync/test/redteam-layout-title.test.ts
Normal file
71
packages/git-sync/test/redteam-layout-title.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildVaultLayout, type PageNode } from '../src/engine/layout.js';
|
||||
import { classifyRenameMoves } from '../src/engine/push.js';
|
||||
import type {
|
||||
ClassifyRenameMovesDeps,
|
||||
MetaSide,
|
||||
RenameMoveAction,
|
||||
} from '../src/engine/push.js';
|
||||
import type { DocmostMdMeta } from '../src/lib/index.js';
|
||||
|
||||
// RED-TEAM finding #4 (two facets):
|
||||
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
|
||||
// equally-titled root pages keeps the bare stem (and which gets the
|
||||
// ` ~slugId` suffix) depends purely on input array order. The layout is
|
||||
// supposed to be a deterministic function of the page SET, so reordering
|
||||
// the input must not move the suffix onto a different page.
|
||||
// (b) The page title derived from a DISAMBIGUATED filename ('Report ~a1.md')
|
||||
// never strips the cosmetic ` ~slugId` suffix, so a pure disambiguation
|
||||
// file-rename is mis-classified as a real title RENAME that would push the
|
||||
// suffix ('Report ~a1') back into Docmost as the page's actual title.
|
||||
|
||||
describe('redteam #4a — buildVaultLayout is stable under input reorder', () => {
|
||||
it('keeps the same stem for page A regardless of input order', () => {
|
||||
const A: PageNode = { id: 'A', title: 'Report', slugId: 'a1', parentPageId: null };
|
||||
const B: PageNode = { id: 'B', title: 'Report', slugId: 'b2', parentPageId: null };
|
||||
|
||||
const l1 = buildVaultLayout([A, B]);
|
||||
const l2 = buildVaultLayout([B, A]);
|
||||
|
||||
// Identity (pageId A) must resolve to the same file stem no matter how the
|
||||
// flat page list happened to be ordered.
|
||||
expect(l2.get('A')?.stem).toBe(l1.get('A')?.stem);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redteam #4b — disambiguation suffix is not a title change', () => {
|
||||
// Mirror production push.ts `titleFromPath` EXACTLY: the synthetic native meta
|
||||
// sets `title = baseName(path) without ".md"`. This is the real derivation the
|
||||
// injected `metaAt` carries in `main`.
|
||||
function titleFromPath(path: string): string {
|
||||
const slash = path.lastIndexOf('/');
|
||||
const base = slash < 0 ? path : path.slice(slash + 1);
|
||||
return base.endsWith('.md') ? base.slice(0, -3) : base;
|
||||
}
|
||||
|
||||
function deps(): ClassifyRenameMovesDeps {
|
||||
const metaAt = (path: string, _side: MetaSide): DocmostMdMeta | null => ({
|
||||
version: 1,
|
||||
title: titleFromPath(path),
|
||||
pageId: 'p1',
|
||||
});
|
||||
// Same enclosing folder (root) on both sides -> no reparent.
|
||||
const resolveParentPageId = (_path: string, _side: MetaSide): string | null => null;
|
||||
return { metaAt, resolveParentPageId };
|
||||
}
|
||||
|
||||
it('does NOT emit a rename when only a ~slugId suffix was appended', () => {
|
||||
// A sibling collision appeared, so the file 'Report.md' was relocated to the
|
||||
// disambiguated 'Report ~a1.md'. The page TITLE in Docmost is still 'Report'.
|
||||
const rms: RenameMoveAction[] = [
|
||||
{ pageId: 'p1', oldPath: 'Report.md', newPath: 'Report ~a1.md' },
|
||||
];
|
||||
|
||||
const [classified] = classifyRenameMoves(rms, deps());
|
||||
|
||||
// Desired behaviour: a pure disambiguation file-rename is cosmetic/local and
|
||||
// must NOT be pushed as a title change. (If any rename WERE emitted it must
|
||||
// carry the real title 'Report', never the suffixed 'Report ~a1'.)
|
||||
expect(classified.rename).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user