feat(sync): 同步盘 + 关系层 (先建关系, 再做文件同步)
## 关系层 (src/main/relations.ts, relation-service.ts) 原来"发现即信任": UDP 广播包里明文带着 driveToken, 同网段任何进程拿到就能浏览 你的整个网盘, 而且没有"授权"这个概念。现在拆成三层: 发现 (临时) -> 关系 (持久, 用户显式建立) -> 授权 (分级, 双方各自独立) 建立关系的方式跟远程终端的授权闸门**完全一致** —— 对方直接弹窗, 不用邀请码: A 点"发起同步关系" -> WS 帧 relation:request -> B 侧弹窗 (复用 ApprovalDialog, kind='relation') -> 同意 -> relation:accept -> 双方落库并交换 per-peer 令牌。 - 令牌: myToken=我签发给对方的, peerToken=对方签发给我的 - **driveToken 不再进广播包**; file-server 在关系令牌匹配不上时回退到旧的全局 token, 但只允许"只读浏览网盘" —— 不给同步区, 也不给写/删 - 关系存 JSON 文件 (原子写), 不做 schema 迁移 - 授权能力: drive(浏览我的网盘) / sync(与我同步文件), 双方各自独立勾选 - 远程终端/端口转发/USB 仍走原来的 remoteAllowPeers 白名单, 没有并进关系层 - 同一对端 60s 内只弹一次窗 (WS 是"发现即建立"的, 不限流就能被刷屏) ## 同步区: 独立于网盘的第二套空间 用户的同步目录可以是任意文件夹 (~/文档/工作), 不必待在网盘目录里。 /drive/* 网盘 -> 共享目录, 要 drive 授权 /sync/* 同步区 -> 某关系专属的同步目录, 要 sync 授权, 路径严格限制在该目录内 顶层目录可以同时和多个设备同步: 每个关系自动分配一个子目录 (~/备份/MECHREVO/, ~/备份/lenovo/), 物理隔开, 同名文件不会互相覆盖 —— "统一目录多个聊天备份"成立。 ## 文件关系表 (src/main/sync-manifest.ts) —— 同步盘地基 核心是**区分"文件被删了"和"文件本来就不存在"**: - **墓碑**: 删除不抹记录, 只标 state='deleted'。直接抹掉的话, 对方离线回来会把 已删文件当成"新增"重新下载 -> 删除永远删不掉; 对方从备份恢复也会让删除被 静默撤销。有墓碑 + seq 比较就都能挡住。 - **seq = Lamport 时钟**: hash 只能答"一样不一样", seq 才能答"谁更晚" —— 判定 删除方向全靠它。扫描顺序必须是 advanceClock -> reconcileLocal -> mergePeer (先收消息再产生事件), 否则跨机器的 seq 不可比, 删除方向会判反。 - **三方对比**: 基线(上次两边一致的 hash) + 本地 + 远端。缺基线才退回 mtime 兜底。 - 墓碑留 90 天 (比大品牌默认 30 天保守), 且要"对方已越过该 seq"才清理。 - **删除传播**: 对方推关系表过来时, 比对基线确认我没改过才删本地那份; 改过则 报冲突 —— 绝不静默丢数据。 - 冲突: 手动同步弹窗对比让用户选方向; 自动同步跳过并记录, 不替用户决定。 ## 其余 - 自动同步 (src/main/auto-sync.ts): 60s 轮询 + 指数退避, 对方上线/开关打开时 立刻跑一轮。 - 同步面板 (SyncPanel.vue): 关系区 + 变更列表 (含"将删除对方/本机") + 冲突对比 弹窗 + 自动同步开关。 - 关系授权弹窗复用远程终端那套 ApprovalDialog。 - 目录穿越防护: 上传/下载/删除的路径都严格限制在各自根目录内, 且**软链归一后 再比一次** (同步目录是用户任意文件夹, 里面出现软链很常见)。 - 跨关系校验: 令牌与 relationId 绑定, A 关系的对方不能读写 B 关系的同步目录。 - 关系令牌在 IPC 返回给渲染层时剥掉, 界面拿不到凭据。 - 建关系/授权/同步/删除传播都写 audit_log。 ## 测试 - npm run selftest 64 通过 / 0 失败 (R关系 / M墓碑删除传播 / Y同步盘) - npm run selftest:sync 26 条全通过 (双节点端到端: 建立关系->同步->删->传播->防复活) ## 文档 docs/relation-and-sync.md —— 三层模型 / 关系建立 / 墓碑与 seq / 三方对比判定表 / 安全要点 / 已知不一致。
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// 同步盘端到端测试 (两个虚拟节点 A/B, 走真实的 WS + HTTP 通道)
|
||||
//
|
||||
// 覆盖的是"用户真正会遇到的路径":
|
||||
// 对方弹窗同意 -> 建立关系 -> 双向同步 -> 改文件 -> 删文件 -> 删除传播 -> 墓碑防复活
|
||||
// 和 scripts/selftest/main.ts 的定位不同: 那边是逐条断言, 这边是**完整链路**,
|
||||
// 用来守住"删了能删掉、删了不会复活"这两条命门。
|
||||
//
|
||||
// 运行: npm run selftest:sync
|
||||
import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
import { ChatServer } from '../../src/main/chat-server'
|
||||
import { ChatClient } from '../../src/main/chat-client'
|
||||
import { FileServer } from '../../src/main/file-server'
|
||||
import { RelationStore } from '../../src/main/relations'
|
||||
import { ManifestStore } from '../../src/main/sync-manifest'
|
||||
import { RelationService } from '../../src/main/relation-service'
|
||||
import { scanSync, applySyncItems } from '../../src/main/sync'
|
||||
import { listPendingApprovals, replyApproval } from '../../src/main/remote/approval'
|
||||
import type { DeviceInfo, WsFrame } from '../../src/main/protocol'
|
||||
|
||||
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms))
|
||||
let failures = 0
|
||||
function check(name: string, cond: any, extra?: string) {
|
||||
if (cond) { console.log(` PASS ${name}`) }
|
||||
else { failures++; console.log(` FAIL ${name}${extra ? ' :: ' + extra : ''}`) }
|
||||
}
|
||||
|
||||
class Node {
|
||||
info: DeviceInfo
|
||||
server: ChatServer
|
||||
client: ChatClient
|
||||
fileServer: FileServer
|
||||
relations: RelationStore
|
||||
manifestStore: ManifestStore
|
||||
relationService!: RelationService
|
||||
syncDir = ''
|
||||
base = ''
|
||||
|
||||
constructor(id: string, name: string) {
|
||||
this.info = {
|
||||
deviceId: id, name, hostname: name, platform: 'linux', version: 1,
|
||||
appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(),
|
||||
}
|
||||
this.base = join(tmpdir(), `lnm-smoke-${id.slice(-4)}-${Date.now()}`)
|
||||
rmSync(this.base, { recursive: true, force: true })
|
||||
mkdirSync(this.base, { recursive: true })
|
||||
this.relations = new RelationStore(join(this.base, 'relations.json'))
|
||||
this.manifestStore = new ManifestStore(join(this.base, 'manifest.json'))
|
||||
this.server = new ChatServer(0)
|
||||
this.client = new ChatClient(this.info)
|
||||
this.fileServer = new FileServer(0, join(this.base, 'dl'), {
|
||||
getDriveRoot: () => join(this.base, 'drive'),
|
||||
getDriveToken: () => `tok-${id}`,
|
||||
isDriveEnabled: () => true,
|
||||
authorize: (token, peerId) => this.relationService
|
||||
? this.relationService.authorizeDrive(token, peerId)
|
||||
: { ok: false, reason: '未就绪' },
|
||||
getSyncScope: (relationId, peerId) => this.relationService?.getSyncScope(relationId, peerId) ?? null,
|
||||
getSyncManifest: ({ relationId }) => this.relationService?.getSyncManifest(relationId) ?? null,
|
||||
onSyncManifest: ({ relationId, wire }) => this.relationService
|
||||
? this.relationService.applyPeerManifest(relationId, wire)
|
||||
: { applied: [], conflicts: [] },
|
||||
})
|
||||
}
|
||||
|
||||
async start() {
|
||||
const cp = await this.server.start()
|
||||
const fp = await this.fileServer.start()
|
||||
this.info.chatPort = cp
|
||||
this.info.filePort = fp
|
||||
mkdirSync(join(this.base, 'drive'), { recursive: true })
|
||||
this.syncDir = join(this.base, 'sync')
|
||||
mkdirSync(this.syncDir, { recursive: true })
|
||||
this.relationService = new RelationService({
|
||||
store: this.relations,
|
||||
manifestStore: this.manifestStore,
|
||||
self: this.info,
|
||||
send: (peerId, frame) => this.client.send(frame, peerId),
|
||||
notify: () => {},
|
||||
})
|
||||
const route = (peer: DeviceInfo, frame: WsFrame) => {
|
||||
if (frame.type === 'relation:request') {
|
||||
this.relationService.handleRelationRequest(peer, frame.payload).catch(() => {})
|
||||
} else if (frame.type === 'relation:accept') {
|
||||
this.relationService.handleRelationAccept(peer, frame.payload)
|
||||
} else if (frame.type === 'relation:reject') {
|
||||
this.relationService.handleRelationReject(peer, frame.payload)
|
||||
}
|
||||
}
|
||||
this.server.on('frame', route)
|
||||
this.client.on('message', route)
|
||||
this.client.on('error', () => {})
|
||||
}
|
||||
|
||||
connectTo(other: Node) { this.client.connectTo({ ...other.info }) }
|
||||
async stop() { try { await this.server.stop() } catch {} ; try { this.client.stop() } catch {} }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const A = new Node('aaaaaaaa-1111-2222-3333-444444444444', 'A机')
|
||||
const B = new Node('bbbbbbbb-1111-2222-3333-444444444444', 'B机')
|
||||
await A.start()
|
||||
await B.start()
|
||||
A.connectTo(B)
|
||||
B.connectTo(A)
|
||||
await sleep(700)
|
||||
|
||||
// ---- 1) 建立关系 (模拟对方在弹窗里点同意) ----
|
||||
const req = A.relationService.requestRelation({
|
||||
peerId: B.info.deviceId, peerName: B.info.name,
|
||||
peerAddress: '127.0.0.1', peerChatPort: B.info.chatPort, peerFilePort: B.info.filePort,
|
||||
syncLocalDir: A.syncDir, syncRemotePath: '', grants: ['drive', 'sync'],
|
||||
})
|
||||
check('发起关系请求成功', req.ok, JSON.stringify(req))
|
||||
await sleep(500)
|
||||
const ask = listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === A.info.deviceId)
|
||||
check('对方弹出了关系授权请求', !!ask)
|
||||
if (ask) {
|
||||
replyApproval(ask.requestId, true, { grants: ['drive', 'sync'], syncLocalDir: B.syncDir, syncRemotePath: '' })
|
||||
}
|
||||
await sleep(700)
|
||||
const relA = A.relations.getRelationByPeer(B.info.deviceId)
|
||||
const relB = B.relations.getRelationByPeer(A.info.deviceId)
|
||||
// 实际同步根 = 顶层目录 + 按关系隔离的子目录 (一个顶层目录管多个聊天备份时各自隔开)
|
||||
const aRoot = join(A.syncDir, relA?.syncSubdir || '')
|
||||
const bRoot = join(B.syncDir, relB?.syncSubdir || '')
|
||||
mkdirSync(aRoot, { recursive: true })
|
||||
mkdirSync(bRoot, { recursive: true })
|
||||
check('A 侧关系 active', relA?.state === 'active', relA?.state)
|
||||
check('B 侧关系 active', relB?.state === 'active', relB?.state)
|
||||
check('双方令牌已交换', !!relA?.peerToken && !!relB?.peerToken)
|
||||
if (!relA || !relB) { await A.stop(); await B.stop(); process.exit(1) }
|
||||
|
||||
// ---- 2) 同步: A 的文件传到 B ----
|
||||
writeFileSync(join(aRoot, 'hello.txt'), 'HELLO-FROM-A')
|
||||
writeFileSync(join(bRoot, 'only-b.txt'), 'ONLY-FROM-B')
|
||||
const ctxA = {
|
||||
peer: B.info, localDir: aRoot, token: relA.peerToken,
|
||||
relationId: relA.relationId, store: A.manifestStore,
|
||||
selfDeviceId: A.info.deviceId,
|
||||
}
|
||||
const scan1 = await scanSync(ctxA)
|
||||
check('扫描成功', scan1.ok, scan1.reason)
|
||||
check('hello.txt 判为待上传', scan1.items.find(i => i.relPath === 'hello.txt')?.action === 'upload',
|
||||
JSON.stringify(scan1.items.map(i => [i.relPath, i.action])))
|
||||
check('only-b.txt 判为待下载', scan1.items.find(i => i.relPath === 'only-b.txt')?.action === 'download',
|
||||
JSON.stringify(scan1.items.map(i => [i.relPath, i.action])))
|
||||
const items1 = scan1.items
|
||||
.filter(i => i.action === 'upload' || i.action === 'download' || i.action === 'delete-local' || i.action === 'delete-remote')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as any }))
|
||||
const ap1 = await applySyncItems({ ...ctxA, items: items1 })
|
||||
check('同步执行成功', ap1.ok, JSON.stringify(ap1.results))
|
||||
check('B 收到了 hello.txt', existsSync(join(bRoot, 'hello.txt')))
|
||||
check('A 收到了 only-b.txt', existsSync(join(aRoot, 'only-b.txt')))
|
||||
check('内容正确', readFileSync(join(bRoot, 'hello.txt'), 'utf8') === 'HELLO-FROM-A')
|
||||
|
||||
// ---- 3) 本地改了 -> 应判 upload (有基线了) ----
|
||||
writeFileSync(join(aRoot, 'hello.txt'), 'HELLO-FROM-A-V2')
|
||||
const scan2 = await scanSync(ctxA)
|
||||
check('改过的文件判为待上传(不是冲突)',
|
||||
scan2.items.find(i => i.relPath === 'hello.txt')?.action === 'upload',
|
||||
JSON.stringify(scan2.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
// ---- 4) 删除传播: A 删了 -> B 那份也要没 ----
|
||||
rmSync(join(aRoot, 'hello.txt'))
|
||||
const scan3 = await scanSync(ctxA)
|
||||
check('删掉的文件判为"将删除对方"',
|
||||
scan3.items.find(i => i.relPath === 'hello.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(scan3.items.map(i => [i.relPath, i.action])))
|
||||
const ap3 = await applySyncItems({
|
||||
...ctxA,
|
||||
items: scan3.items.filter(i => i.action === 'delete-remote' || i.action === 'delete-local' || i.action === 'upload' || i.action === 'download')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as any })),
|
||||
})
|
||||
check('删除传播执行成功', ap3.ok, JSON.stringify(ap3.results))
|
||||
check('B 那份也被删了', !existsSync(join(bRoot, 'hello.txt')))
|
||||
|
||||
// ---- 5) 墓碑: 再扫一次不该把它当成"对方新增"重新下载 ----
|
||||
const scan4 = await scanSync(ctxA)
|
||||
const again = scan4.items.find(i => i.relPath === 'hello.txt')
|
||||
check('墓碑生效: 不会被重新下载', !again || again.action === 'same',
|
||||
JSON.stringify(scan4.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
// ---- 6) 授权边界: 错令牌被拒 ----
|
||||
const bad = await scanSync({ ...ctxA, token: 'wrong-token' })
|
||||
check('错令牌被拒', !bad.ok, bad.reason)
|
||||
|
||||
// ---- 7) 反向删除传播: B 删了 -> A 那份也要没 ----
|
||||
rmSync(join(bRoot, 'only-b.txt'))
|
||||
const ctxB = {
|
||||
peer: A.info, localDir: bRoot, token: relB.peerToken,
|
||||
relationId: relB.relationId, store: B.manifestStore,
|
||||
selfDeviceId: B.info.deviceId,
|
||||
}
|
||||
const scanB = await scanSync(ctxB)
|
||||
check('B 侧识别出"我删了 only-b.txt, 要传播给对方"',
|
||||
scanB.items.find(i => i.relPath === 'only-b.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(scanB.items.map(i => [i.relPath, i.action])))
|
||||
await applySyncItems({
|
||||
...ctxB,
|
||||
items: scanB.items.filter(i => i.action !== 'same').map(i => ({ relPath: i.relPath, action: i.action as any })),
|
||||
})
|
||||
check('A 那份 only-b.txt 也被删了', !existsSync(join(aRoot, 'only-b.txt')))
|
||||
|
||||
// ---- 7b) 完整删除传播链路: A 删 -> A 推关系表 -> B 那边自动删掉本地那份 ----
|
||||
writeFileSync(join(aRoot, 'both.txt'), 'SHARED')
|
||||
await applySyncItems({ ...ctxA, items: [{ relPath: 'both.txt', action: 'upload' }] })
|
||||
const scanB2 = await scanSync(ctxB) // B 拉一次, 建立基线
|
||||
await applySyncItems({
|
||||
...ctxB,
|
||||
items: scanB2.items.filter(i => i.action === 'download')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as any })),
|
||||
})
|
||||
check('both.txt 同步到了 B', existsSync(join(bRoot, 'both.txt')))
|
||||
rmSync(join(aRoot, 'both.txt'))
|
||||
const scanDel = await scanSync(ctxA)
|
||||
check('A 侧判为"将删除对方"',
|
||||
scanDel.items.find(i => i.relPath === 'both.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(scanDel.items.map(i => [i.relPath, i.action])))
|
||||
await applySyncItems({ ...ctxA, items: [{ relPath: 'both.txt', action: 'delete-remote' }] })
|
||||
await sleep(400) // 等对方收到并应用
|
||||
check('删除传播到 B: B 本地那份也没了', !existsSync(join(bRoot, 'both.txt')))
|
||||
|
||||
// ---- 8) 冲突: 两边都改同一个文件 -> 判 conflict, 不静默覆盖 ----
|
||||
writeFileSync(join(aRoot, 'conf.txt'), 'A-VERSION')
|
||||
await applySyncItems({ ...ctxA, items: [{ relPath: 'conf.txt', action: 'upload' }] })
|
||||
writeFileSync(join(aRoot, 'conf.txt'), 'A-VERSION-2')
|
||||
writeFileSync(join(bRoot, 'conf.txt'), 'B-VERSION-2')
|
||||
const scanC = await scanSync(ctxA)
|
||||
check('两边都改过 -> 判冲突',
|
||||
scanC.items.find(i => i.relPath === 'conf.txt')?.action === 'conflict',
|
||||
JSON.stringify(scanC.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
// ---- 9) 复活防护: 对方拿更早的 seq 声称文件还在 -> 墓碑赢 ----
|
||||
const M = require('../../src/main/sync-manifest')
|
||||
const man = { relationId: 'r1', clock: 0, remoteSeq: 0, entries: {} as any }
|
||||
const snap = (relPath: string, hash: string) => ({ relPath, size: 10, mtime: Date.now(), hash })
|
||||
// 先建立基线: 两边一致
|
||||
M.reconcileLocal(man, [snap('z.txt', 'H1')])
|
||||
const peerMap = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H1', size: 10, mtime: 1, state: 'present', seq: 1, deletedAt: 0 }]])
|
||||
M.mergePeer(man, peerMap)
|
||||
M.updateBaseline(man, 'z.txt', 'H1', 10, 1)
|
||||
// 本地删除 -> 墓碑
|
||||
M.reconcileLocal(man, [])
|
||||
const tombSeq = man.entries['z.txt'].mySeq
|
||||
check('本地删除记成了墓碑', man.entries['z.txt'].myState === 'deleted' && tombSeq > 0)
|
||||
// 对方用一个更早的 seq 声称文件还在 -> 墓碑应该赢
|
||||
const stalePeer = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H1', size: 10, mtime: 1, state: 'present', seq: 1, deletedAt: 0 }]])
|
||||
M.mergePeer(man, stalePeer)
|
||||
const planStale = M.planSync(man, [], stalePeer)
|
||||
check('墓碑赢: 不会让已删文件复活',
|
||||
planStale.items.find(i => i.relPath === 'z.txt')?.action === 'delete-remote',
|
||||
JSON.stringify(planStale.items.map(i => [i.relPath, i.action])))
|
||||
// 对方用更新的 seq 声称文件还在 -> 接受复活
|
||||
const freshPeer = new Map([['z.txt', { fileId: 'f1', relPath: 'z.txt', hash: 'H2', size: 10, mtime: 2, state: 'present', seq: tombSeq + 5, deletedAt: 0 }]])
|
||||
M.mergePeer(man, freshPeer)
|
||||
const planFresh = M.planSync(man, [], freshPeer)
|
||||
check('对方 seq 更新 -> 接受复活(下载)',
|
||||
planFresh.items.find(i => i.relPath === 'z.txt')?.action === 'download',
|
||||
JSON.stringify(planFresh.items.map(i => [i.relPath, i.action])))
|
||||
|
||||
await A.stop()
|
||||
await B.stop()
|
||||
console.log(failures === 0 ? '\n全部通过' : `\n${failures} 条失败`)
|
||||
process.exit(failures === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error('崩溃:', e); process.exit(1) })
|
||||
Reference in New Issue
Block a user