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:
+742
-3
@@ -6,7 +6,7 @@
|
||||
// npm run selftest # 全部
|
||||
// npm run selftest -- terminal # 只跑某些用例
|
||||
import { spawn, ChildProcess } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import * as fs from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -20,13 +20,24 @@ import { FileServer } from '../../src/main/file-server'
|
||||
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
|
||||
import { computeWsState } from '../../src/main/ws-state'
|
||||
import { safeFileName } from '../../src/main/safe-name'
|
||||
import { scanLocalDir, scanSync, applySyncItems, type SyncFileInfo, type SyncContext } from '../../src/main/sync'
|
||||
import {
|
||||
ManifestStore, planSync, mergePeer, markLocalDeleted, reconcileLocal,
|
||||
type Manifest, type ManifestWireEntry,
|
||||
} from '../../src/main/sync-manifest'
|
||||
import { RelationStore, type Grant, type Relation } from '../../src/main/relations'
|
||||
import { RelationService } from '../../src/main/relation-service'
|
||||
import { listPendingApprovals, replyApproval } from '../../src/main/remote/approval'
|
||||
import { defaultCwd, defaultShell } from '../../src/main/remote/shell'
|
||||
import { TerminalManager } from '../../src/main/remote/terminal'
|
||||
import { ForwardManager } from '../../src/main/remote/forward'
|
||||
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
|
||||
import { updateSettings } from '../../src/main/settings'
|
||||
import { listAudit } from '../../src/main/db'
|
||||
import type { DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame } from '../../src/main/protocol'
|
||||
import type {
|
||||
DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame,
|
||||
RelationRequestFrame, RelationAcceptFrame, RelationRejectFrame,
|
||||
} from '../../src/main/protocol'
|
||||
|
||||
// ============ 小型测试框架 ============
|
||||
interface CaseResult { name: string; ok: boolean; detail: string; ms: number }
|
||||
@@ -66,6 +77,21 @@ class Node {
|
||||
events: UiEvent[] = []
|
||||
driveDir = ''
|
||||
driveToken = ''
|
||||
// ---- 关系层 (同步盘) ----
|
||||
/** 关系库 (JSON 文件) */
|
||||
relations!: RelationStore
|
||||
/** 文件关系表仓库 (墓碑 + 序号) */
|
||||
manifestStore!: ManifestStore
|
||||
/** 关系服务 (建关系/授权/应用对方的关系表) */
|
||||
relationService!: RelationService
|
||||
/** 关系库文件路径 (自测里要重新打开验证持久化) */
|
||||
relationsFile = ''
|
||||
/** 文件关系表文件路径 */
|
||||
manifestFile = ''
|
||||
/** 本节点的同步目录 (自己选的任意本地目录, 不必在网盘里) */
|
||||
syncLocalDir = ''
|
||||
/** 实际同步根 = 顶层目录 + 关系子目录 (关系建立后才知道) */
|
||||
syncRoot = ''
|
||||
|
||||
constructor(id: string, name: string) {
|
||||
this.info = {
|
||||
@@ -78,6 +104,15 @@ class Node {
|
||||
getDriveRoot: () => this.driveDir,
|
||||
getDriveToken: () => this.driveToken,
|
||||
isDriveEnabled: () => true,
|
||||
// 新版: 按关系里的 per-peer 令牌鉴权 (网盘 / 同步区各要各的授权)
|
||||
authorize: (token, peerId) => this.relationService
|
||||
? this.relationService.authorizeDrive(token, peerId)
|
||||
: { ok: false, reason: '未就绪' },
|
||||
getSyncScope: (relationId) => this.relationService?.getSyncScope(relationId) ?? null,
|
||||
getSyncManifest: ({ relationId }) => this.relationService?.getSyncManifest(relationId) ?? null,
|
||||
onSyncManifest: ({ relationId, wire }) => this.relationService
|
||||
? this.relationService.applyPeerManifest(relationId, wire)
|
||||
: { applied: [], conflicts: [] },
|
||||
})
|
||||
this.terminal = new TerminalManager(this.client, id)
|
||||
this.forward = new ForwardManager(this.client)
|
||||
@@ -99,6 +134,27 @@ class Node {
|
||||
this.driveToken = `tok-${this.info.deviceId.slice(-4)}`
|
||||
this.info.driveToken = this.driveToken
|
||||
|
||||
// ---- 关系层: 关系库 + 文件关系表 + 关系服务 ----
|
||||
const tmpBase = join(tmpdir(), `lnm-selftest-rel-${this.info.deviceId.slice(-4)}-${Date.now()}`)
|
||||
rmSync(tmpBase, { recursive: true, force: true })
|
||||
mkdirSync(tmpBase, { recursive: true })
|
||||
this.relationsFile = join(tmpBase, 'relations.json')
|
||||
this.manifestFile = join(tmpBase, 'manifest.json')
|
||||
this.relations = new RelationStore(this.relationsFile)
|
||||
this.manifestStore = new ManifestStore(this.manifestFile)
|
||||
this.relationService = new RelationService({
|
||||
store: this.relations,
|
||||
manifestStore: this.manifestStore,
|
||||
self: this.info,
|
||||
send: (peerId, frame) => this.client.send(frame, peerId),
|
||||
notify: (event, payload) => this.events.push({ kind: event, peerId: payload?.peerId }),
|
||||
})
|
||||
// 本节点的同步目录: 自己选的任意本地目录 (独立于网盘)
|
||||
this.syncLocalDir = join(tmpBase, 'sync')
|
||||
rmSync(this.syncLocalDir, { recursive: true, force: true })
|
||||
mkdirSync(this.syncLocalDir, { recursive: true })
|
||||
this.syncRoot = this.syncLocalDir
|
||||
|
||||
// ---- 与 src/main/ipc.ts 完全一致的帧路由 ----
|
||||
const TRACE = !!process.env.LNM_TRACE
|
||||
const route = (peer: DeviceInfo, frame: WsFrame) => {
|
||||
@@ -113,6 +169,13 @@ class Node {
|
||||
this.forward.handleIncoming(peer, frame.payload as ForwardFrame)
|
||||
} else if (frame.type === 'usb') {
|
||||
this.usb.handleIncoming(peer, frame.payload as UsbFrame)
|
||||
} else if (frame.type === 'relation:request') {
|
||||
this.relationService.handleRelationRequest(peer, frame.payload as RelationRequestFrame)
|
||||
.catch((e) => console.warn(`[${this.info.name}] relation request error:`, e?.message))
|
||||
} else if (frame.type === 'relation:accept') {
|
||||
this.relationService.handleRelationAccept(peer, frame.payload as RelationAcceptFrame)
|
||||
} else if (frame.type === 'relation:reject') {
|
||||
this.relationService.handleRelationReject(peer, frame.payload as RelationRejectFrame)
|
||||
}
|
||||
}
|
||||
this.server.on('frame', route)
|
||||
@@ -175,6 +238,11 @@ class Node {
|
||||
|
||||
function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') }
|
||||
|
||||
// 读文本文件, 读不到就当空串 (断言里好写)
|
||||
function readText(p: string): string {
|
||||
try { return readFileSync(p, 'utf8') } catch { return '' }
|
||||
}
|
||||
|
||||
// ============ 小工具 ============
|
||||
function startHttpServer(port: number, body: string): Promise<Server> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -228,6 +296,123 @@ function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise<vo
|
||||
})
|
||||
}
|
||||
|
||||
// 等待某个条件成立并**返回找到的东西** (关系授权弹窗之类的异步产物)
|
||||
async function waitForItem<T>(fn: () => T | undefined | null | false, timeoutMs = 5000, stepMs = 30): Promise<T> {
|
||||
const t0 = Date.now()
|
||||
for (;;) {
|
||||
const v = fn()
|
||||
if (v) return v
|
||||
if (Date.now() - t0 > timeoutMs) throw new Error('等待超时')
|
||||
await sleep(stepMs)
|
||||
}
|
||||
}
|
||||
|
||||
// 两个节点双向连上 (模拟 discovery 找到对方后双方都连)
|
||||
async function link(a: Node, b: Node) {
|
||||
a.connectTo(b)
|
||||
b.connectTo(a)
|
||||
await waitFor(() => a.client.isOpen(b.info.deviceId) && b.client.isOpen(a.info.deviceId), 8000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立一条关系: 发起方发请求 -> 对方弹窗 -> 同意 -> 双方关系落库且 active。
|
||||
* 授权队列 (approval.ts) 是模块级单例, 用 peerId 区分是哪条请求。
|
||||
*/
|
||||
async function establishRelation(
|
||||
from: Node, to: Node,
|
||||
opts?: { grants?: Grant[]; peerGrants?: Grant[]; fromPath?: string; toPath?: string },
|
||||
): Promise<{ relationId: string; fromRel: Relation; toRel: Relation }> {
|
||||
const grants = opts?.grants ?? ['drive', 'sync']
|
||||
const peerGrants = opts?.peerGrants ?? grants
|
||||
const r = from.relationService.requestRelation({
|
||||
peerId: to.info.deviceId,
|
||||
peerName: to.info.name,
|
||||
peerAddress: to.info.address,
|
||||
peerChatPort: to.info.chatPort,
|
||||
peerFilePort: to.info.filePort,
|
||||
syncLocalDir: from.syncLocalDir,
|
||||
syncRemotePath: opts?.fromPath ?? `同步/${from.info.name}`,
|
||||
grants,
|
||||
})
|
||||
assert(r.ok, `发起关系失败: ${r.reason}`)
|
||||
assert(!!r.relationId, '发起关系没有返回 relationId')
|
||||
const relationId = r.relationId!
|
||||
// 对方那边应该弹出授权窗口
|
||||
const req = await waitForItem(
|
||||
() => listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === from.info.deviceId),
|
||||
5000,
|
||||
).catch(() => { throw new Error('对方没有弹出关系授权窗口') })
|
||||
const replied = replyApproval(req.requestId, true, {
|
||||
grants: peerGrants,
|
||||
syncLocalDir: to.syncLocalDir,
|
||||
syncRemotePath: opts?.toPath ?? `同步/${to.info.name}`,
|
||||
})
|
||||
assert(replied, '回复关系授权失败 (请求已超时?)')
|
||||
// 双方关系都要变成 active
|
||||
await waitFor(() => from.relations.getRelation(relationId)?.state === 'active', 5000)
|
||||
.catch(() => { throw new Error('发起方的关系没有变成 active') })
|
||||
await waitFor(() => to.relations.getRelation(relationId)?.state === 'active', 5000)
|
||||
.catch(() => { throw new Error('对方的关系没有落库') })
|
||||
const fromRel = from.relations.getRelation(relationId)!
|
||||
const toRel = to.relations.getRelation(relationId)!
|
||||
// 实际同步根 = 顶层目录 + 按关系隔离的子目录 (测试要按它来放文件)
|
||||
from.syncRoot = join(from.syncLocalDir, fromRel.syncSubdir || '')
|
||||
to.syncRoot = join(to.syncLocalDir, toRel.syncSubdir || '')
|
||||
mkdirSync(from.syncRoot, { recursive: true })
|
||||
mkdirSync(to.syncRoot, { recursive: true })
|
||||
return { relationId, fromRel, toRel }
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步上下文: 本地目录 + 对方同步区 + 关系里的对方令牌 + 关系表仓库。
|
||||
* token 用关系里的 peerToken (对方签发给我的那个), peerId 传我的 deviceId。
|
||||
*/
|
||||
function syncCtx(node: Node, peer: Node, relationId: string): SyncContext {
|
||||
const rel = node.relations.getRelation(relationId)
|
||||
assert(!!rel, `关系不存在: ${relationId}`)
|
||||
const token = rel!.peerToken
|
||||
assert(!!token, '关系里没有对方的令牌')
|
||||
return {
|
||||
peer: { ...peer.info, driveToken: token },
|
||||
localDir: node.syncRoot,
|
||||
remotePath: '',
|
||||
token,
|
||||
relationId,
|
||||
store: node.manifestStore,
|
||||
peerSyncRoot: rel!.peerSyncRoot,
|
||||
// 请求里要带**我方**的 deviceId (服务端靠它确认令牌确实是签给对方的)
|
||||
selfDeviceId: node.info.deviceId,
|
||||
}
|
||||
}
|
||||
|
||||
// 发一个 HTTP 请求, 拿状态码 + 原始文本 (用来精确断言 403 之类的)
|
||||
function httpJson(port: number, path: string, opts?: {
|
||||
method?: string; headers?: Record<string, string>; body?: Buffer | string; timeoutMs?: number
|
||||
}): Promise<{ status: number; text: string; data: any }> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false
|
||||
const finish = (status: number, text: string) => {
|
||||
if (done) return
|
||||
done = true
|
||||
let data: any = null
|
||||
try { data = JSON.parse(text) } catch { /* 非 JSON 也行 */ }
|
||||
resolve({ status, text, data })
|
||||
}
|
||||
const req = httpRequest({
|
||||
host: '127.0.0.1', port, method: opts?.method || 'GET', path,
|
||||
headers: opts?.headers || {}, timeout: opts?.timeoutMs ?? 15000,
|
||||
}, (res) => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('data', (c) => chunks.push(c))
|
||||
res.on('end', () => finish(res.statusCode || 0, Buffer.concat(chunks).toString('utf8')))
|
||||
})
|
||||
req.on('timeout', () => { req.destroy(); finish(0, '') })
|
||||
req.on('error', () => finish(0, ''))
|
||||
if (opts?.body) req.end(opts.body)
|
||||
else req.end()
|
||||
})
|
||||
}
|
||||
|
||||
// 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写
|
||||
async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> {
|
||||
const userPath = join(linkDir, 'user')
|
||||
@@ -277,11 +462,23 @@ async function main() {
|
||||
const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A')
|
||||
step('建节点 B')
|
||||
const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B')
|
||||
step('建节点 E (只给 sync 授权的对端, 测授权分级/多关系隔离)')
|
||||
const E = new Node('peer-eeeeeeee-0000-4000-8000-00000000000e', '同步授权E')
|
||||
step('建节点 F (测拒绝)')
|
||||
const F = new Node('peer-ffffffff-0000-4000-8000-00000000000f', '关系测试F')
|
||||
step('建节点 G (测吊销)')
|
||||
const G = new Node('peer-99999999-0000-4000-8000-000000000009', '关系测试G')
|
||||
step('启动 A')
|
||||
await A.start()
|
||||
step('启动 B')
|
||||
await B.start()
|
||||
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort})`)
|
||||
step('启动 E')
|
||||
await E.start()
|
||||
step('启动 F')
|
||||
await F.start()
|
||||
step('启动 G')
|
||||
await G.start()
|
||||
step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort}) E(:${E.info.chatPort}) F(:${F.info.chatPort}) G(:${G.info.chatPort})`)
|
||||
|
||||
// 双向连通 (模拟 discovery 找到对方后双方都连上)
|
||||
step('建立 WS 连接')
|
||||
@@ -768,6 +965,545 @@ async function main() {
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 关系 (同步盘授权) ============
|
||||
console.log('\n\x1b[1m[关系]\x1b[0m')
|
||||
// A-B: 主关系 (R1 建的, 后面 M 组要用); A-E: 只给 sync 授权的关系 (R4 建的, M8 多关系隔离要用)
|
||||
let relAB = ''
|
||||
let relAE = ''
|
||||
if (want('relation') || want('rel')) {
|
||||
// W3 那组把 A-B 的连接掐了, 这里重新连上 (真实客户端断了也会重连)
|
||||
await link(A, B)
|
||||
await link(A, E)
|
||||
await link(A, F)
|
||||
await link(A, G)
|
||||
|
||||
await runCase('R1 建立关系: 发起 -> 对方弹窗 -> 同意 -> 双方关系落库且 active, 令牌互换', async () => {
|
||||
const { relationId, fromRel, toRel } = await establishRelation(A, B)
|
||||
relAB = relationId
|
||||
assert(fromRel.state === 'active', `发起方关系状态不对: ${fromRel.state}`)
|
||||
assert(toRel.state === 'active', `对方关系状态不对: ${toRel.state}`)
|
||||
assert(!!fromRel.myToken && !!fromRel.peerToken, '令牌没互换 (有一边是空的)')
|
||||
assert(fromRel.myToken !== fromRel.peerToken, '两个令牌是同一个 (不该是)')
|
||||
assert(toRel.myToken === fromRel.peerToken, '对方签发的令牌对不上')
|
||||
assert(toRel.peerToken === fromRel.myToken, '对方拿到的令牌对不上')
|
||||
assert(toRel.syncLocalDir === B.syncLocalDir, '对方的同步目录没落库')
|
||||
assert(fromRel.peerGrants.includes('sync') && toRel.peerGrants.includes('sync'), '授权能力没互换')
|
||||
})
|
||||
|
||||
await runCase('R2 对方拒绝: 发起方的 pending 关系被清理', async () => {
|
||||
const r = A.relationService.requestRelation({
|
||||
peerId: F.info.deviceId, peerName: F.info.name, peerAddress: F.info.address,
|
||||
peerChatPort: F.info.chatPort, peerFilePort: F.info.filePort,
|
||||
syncLocalDir: A.syncLocalDir, syncRemotePath: '同步/F', grants: ['drive', 'sync'],
|
||||
})
|
||||
assert(r.ok, `发起失败: ${r.reason}`)
|
||||
assert(A.relations.getRelation(r.relationId!)?.state === 'pending', '发起方没有落 pending 关系')
|
||||
const req = await waitForItem(
|
||||
() => listPendingApprovals().find(x => x.kind === 'relation' && x.peerId === A.info.deviceId),
|
||||
).catch(() => { throw new Error('对方没有弹出关系授权窗口') })
|
||||
assert(replyApproval(req.requestId, false), '回复拒绝失败')
|
||||
await waitFor(() => !A.relations.getRelation(r.relationId!), 5000)
|
||||
.catch(() => { throw new Error('对方拒绝后, 发起方的 pending 关系还在') })
|
||||
})
|
||||
|
||||
await runCase('R3 per-peer 令牌: 关系令牌能访问对方网盘; 错令牌 403; 令牌与 peerId 不匹配 403', async () => {
|
||||
const rel = A.relations.getRelation(relAB)
|
||||
assert(!!rel, '前置条件不成立: A-B 关系不存在')
|
||||
const token = rel!.peerToken
|
||||
assert(!!token, '关系里没有对方的令牌')
|
||||
// 1) 关系里的对方令牌 -> 能访问对方网盘
|
||||
const ok = await listDrive({ ...B.info, driveToken: token }, '')
|
||||
assert(ok.ok, `用关系令牌访问对方网盘失败: ${ok.reason}`)
|
||||
assert((ok.entries || []).some(e => e.name === 'hello.txt'), '列出来的不是对方的网盘内容')
|
||||
// 2) 错令牌 -> 403
|
||||
const bad = await httpJson(B.info.filePort, `/drive/scan?token=wrong-token&peerId=${encodeURIComponent(A.info.deviceId)}`)
|
||||
assert(bad.status === 403, `错令牌应该 403, 实际 ${bad.status}`)
|
||||
// 3) 令牌跟 peerId 对不上 -> 403 (这枚令牌是发给 A 的, 不能拿给别的设备用)
|
||||
const mismatch = await httpJson(B.info.filePort,
|
||||
`/drive/scan?token=${encodeURIComponent(token)}&peerId=${encodeURIComponent(F.info.deviceId)}`)
|
||||
assert(mismatch.status === 403, `令牌与设备不匹配应该 403, 实际 ${mismatch.status}`)
|
||||
})
|
||||
|
||||
await runCase('R4 授权分级: 只给 sync 时网盘 403 (/drive/scan), 但同步区能访问 (/sync/scan 200)', async () => {
|
||||
const { relationId } = await establishRelation(A, E, { grants: ['drive', 'sync'], peerGrants: ['sync'] })
|
||||
relAE = relationId
|
||||
const rel = A.relations.getRelation(relationId)!
|
||||
const q = `token=${encodeURIComponent(rel.peerToken)}&peerId=${encodeURIComponent(A.info.deviceId)}`
|
||||
// 网盘: 没给 drive -> 403
|
||||
const drive = await httpJson(E.info.filePort, `/drive/scan?${q}`)
|
||||
assert(drive.status === 403, `只给 sync 时网盘应该 403, 实际 ${drive.status}`)
|
||||
// 同步区: 给了 sync -> 200
|
||||
const sync = await httpJson(E.info.filePort, `/sync/scan?${q}&relationId=${encodeURIComponent(relationId)}&hash=1`)
|
||||
assert(sync.status === 200, `只给 sync 时同步区应该 200, 实际 ${sync.status}`)
|
||||
assert(sync.data?.ok === true, `同步区返回不对: ${sync.text.slice(0, 120)}`)
|
||||
})
|
||||
|
||||
await runCase('R5 吊销关系后: 旧令牌立刻失效 (403)', async () => {
|
||||
// 注意: 用独立的节点 G —— 对方对同一对端 60s 内只弹一次窗 (防骚扰),
|
||||
// R2 刚用过 F, 再用 F 会被直接回绝 (请求过于频繁)
|
||||
const { relationId } = await establishRelation(A, G)
|
||||
const gRel = G.relations.getRelation(relationId)!
|
||||
const oldToken = gRel.peerToken // G 手里能访问 A 网盘的那枚 (= A 的 myToken)
|
||||
assert(!!oldToken, '关系里没有令牌')
|
||||
const before = await httpJson(A.info.filePort,
|
||||
`/drive/scan?token=${encodeURIComponent(oldToken)}&peerId=${encodeURIComponent(G.info.deviceId)}`)
|
||||
assert(before.status === 200, `吊销前应该能访问对方网盘, 实际 ${before.status}`)
|
||||
const rev = A.relationService.revokeRelation(relationId)
|
||||
assert(rev.ok, '吊销失败')
|
||||
const after = await httpJson(A.info.filePort,
|
||||
`/drive/scan?token=${encodeURIComponent(oldToken)}&peerId=${encodeURIComponent(G.info.deviceId)}`)
|
||||
assert(after.status === 403, `吊销后旧令牌应该 403, 实际 ${after.status}`)
|
||||
})
|
||||
|
||||
await runCase('R6 关系是持久化的: 重新 new 一个 RelationStore 读同一个文件, 关系还在', async () => {
|
||||
const rel = A.relations.getRelation(relAB)
|
||||
assert(!!rel, '前置条件不成立: A-B 关系不存在')
|
||||
const store2 = new RelationStore(A.relationsFile)
|
||||
const again = store2.getRelation(relAB!)
|
||||
assert(!!again, '重新打开关系库文件后, 关系不见了')
|
||||
assert(again!.state === 'active', `状态不对: ${again!.state}`)
|
||||
assert(again!.peerId === B.info.deviceId, '对方的 deviceId 不对')
|
||||
assert(!!again!.myToken && !!again!.peerToken, '令牌丢了')
|
||||
assert(!!store2.getActiveRelationByPeer(B.info.deviceId), '按 peerId 查不到关系')
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 文件关系表 / 删除传播 ============
|
||||
console.log('\n\x1b[1m[文件关系表 / 删除传播]\x1b[0m')
|
||||
if (want('manifest') || want('sync-manifest')) {
|
||||
// 关系是前置条件: 单独跑这一组时自己建一条
|
||||
let mSetupErr = ''
|
||||
try {
|
||||
await link(A, B)
|
||||
await link(A, E)
|
||||
if (!A.relations.getActiveRelationByPeer(B.info.deviceId)) {
|
||||
relAB = (await establishRelation(A, B)).relationId
|
||||
}
|
||||
if (!A.relations.getActiveRelationByPeer(E.info.deviceId)) {
|
||||
relAE = (await establishRelation(A, E, { grants: ['drive', 'sync'], peerGrants: ['sync'] })).relationId
|
||||
}
|
||||
} catch (e: any) {
|
||||
mSetupErr = String(e?.message || e)
|
||||
}
|
||||
const needSetup = () => assert(!mSetupErr && !!relAB, `前置条件不成立: ${mSetupErr || 'A-B 关系没建起来'}`)
|
||||
|
||||
// 准备文件: A 有 a.txt / shared.txt, B 有 b.txt
|
||||
writeFileSync(join(A.syncRoot, 'a.txt'), 'A-CONTENT')
|
||||
writeFileSync(join(A.syncRoot, 'shared.txt'), 'FROM-A')
|
||||
writeFileSync(join(B.syncRoot, 'b.txt'), 'B-CONTENT')
|
||||
|
||||
await runCase('M1 首次同步建立基线: A 的文件上传到 B, B 的文件下载到 A, 两边内容一致', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const items = scan.items
|
||||
.filter(i => i.action === 'upload' || i.action === 'download')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' }))
|
||||
const r = await applySyncItems({ ...ctx, items })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
// A 的文件到了 B
|
||||
assert(existsSync(join(B.syncRoot, 'a.txt')), 'a.txt 没传到对方')
|
||||
assert(readText(join(B.syncRoot, 'a.txt')) === 'A-CONTENT', 'a.txt 内容不对')
|
||||
assert(existsSync(join(B.syncRoot, 'shared.txt')), 'shared.txt 没传到对方')
|
||||
assert(readText(join(B.syncRoot, 'shared.txt')) === 'FROM-A', 'shared.txt 内容不对')
|
||||
// B 的文件到了 A
|
||||
assert(existsSync(join(A.syncRoot, 'b.txt')), 'b.txt 没下载到本地')
|
||||
assert(readText(join(A.syncRoot, 'b.txt')) === 'B-CONTENT', 'b.txt 内容不对')
|
||||
// 基线建起来了 (后面判"谁改了"全靠它)
|
||||
const man = A.manifestStore.loadManifest(relAB)
|
||||
assert(!!man.entries['a.txt']?.baseHash, 'a.txt 没有基线')
|
||||
assert(!!man.entries['b.txt']?.baseHash, 'b.txt 没有基线')
|
||||
})
|
||||
|
||||
await runCase('M2 本地改了文件: 判定为 upload (不是 conflict) -> 同步后对方内容更新', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
writeFileSync(join(A.syncRoot, 'a.txt'), 'A-EDITED')
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const it = scan.items.find(i => i.relPath === 'a.txt')
|
||||
assert(!!it, 'a.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'upload', `本地改过应判 upload, 实际 ${it!.action}`)
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'a.txt', action: 'upload' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
assert(readText(join(B.syncRoot, 'a.txt')) === 'A-EDITED', '对方内容没更新')
|
||||
})
|
||||
|
||||
await runCase('M3 本地删了文件: 判定为 delete-remote -> 同步后对方那份也没了 (删除传播)', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
rmSync(join(A.syncRoot, 'a.txt'), { force: true })
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const it = scan.items.find(i => i.relPath === 'a.txt')
|
||||
assert(!!it, 'a.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it!.action}`)
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'a.txt', action: 'delete-remote' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
assert(!existsSync(join(B.syncRoot, 'a.txt')), '对方那份还在 (删除没传播)')
|
||||
})
|
||||
|
||||
await runCase('M4 对方删了文件: 判定为 delete-local -> 同步后本地那份也没了', async () => {
|
||||
needSetup()
|
||||
const ctxA = syncCtx(A, B, relAB)
|
||||
const ctxB = syncCtx(B, A, relAB)
|
||||
// 对方先删掉, 再扫描一次记墓碑 (这一步不 apply —— 删除是通过关系表传播的)
|
||||
rmSync(join(B.syncRoot, 'b.txt'), { force: true })
|
||||
const scanB = await scanSync(ctxB)
|
||||
assert(scanB.ok, `对方扫描失败: ${scanB.reason}`)
|
||||
// 我这边扫描 -> 从对方的关系表里看到墓碑 -> 判 delete-local
|
||||
const scanA = await scanSync(ctxA)
|
||||
assert(scanA.ok, `扫描失败: ${scanA.reason}`)
|
||||
const it = scanA.items.find(i => i.relPath === 'b.txt')
|
||||
assert(!!it, 'b.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'delete-local', `对方删了应判 delete-local, 实际 ${it!.action}`)
|
||||
const r = await applySyncItems({ ...ctxA, items: [{ relPath: 'b.txt', action: 'delete-local' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
assert(!existsSync(join(A.syncRoot, 'b.txt')), '本地那份还在 (删除没传播)')
|
||||
})
|
||||
|
||||
await runCase('M5 删除不会被撤销: 删掉并同步后再扫一次, 不会被当成"对方新增"重新下载 (墓碑生效)', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const it = scan.items.find(i => i.relPath === 'b.txt')
|
||||
assert(!!it, 'b.txt 没出现在扫描结果里')
|
||||
assert(it!.action !== 'download', `墓碑失效, 被当成对方新增要重新下载: ${it!.action}`)
|
||||
assert(!existsSync(join(A.syncRoot, 'b.txt')), '本地文件复活了')
|
||||
// 墓碑还在关系表里
|
||||
const e = A.manifestStore.loadManifest(relAB).entries['b.txt']
|
||||
assert(!!e, '关系表里没有 b.txt 的记录')
|
||||
assert(e!.myState === 'deleted', `墓碑状态不对: ${e!.myState}`)
|
||||
assert(e!.mySeq > 0, '墓碑没有序号')
|
||||
})
|
||||
|
||||
await runCase('M6 复活防护: 对方拿一个更早的 seq 声称文件还在 -> 墓碑仍然赢 (保持删除), 不会被复活', async () => {
|
||||
// 单元级: 构造一个"我删了"的墓碑, 对方拿更早的序号声称文件还在
|
||||
const store = new ManifestStore(join(tmpdir(), `lnm-selftest-m6-${Date.now()}.json`))
|
||||
const man: Manifest = store.loadManifest('rel-m6')
|
||||
const H = 'a'.repeat(64)
|
||||
man.clock = 10
|
||||
man.entries['z.txt'] = {
|
||||
fileId: 'file-m6', relPath: 'z.txt',
|
||||
baseHash: H, baseSize: 1, baseMtime: 0,
|
||||
myHash: H, mySize: 1, myMtime: 0, myState: 'present', mySeq: 1,
|
||||
peerHash: H, peerSize: 1, peerMtime: 0, peerState: 'present', peerSeq: 9,
|
||||
deletedAt: 0,
|
||||
}
|
||||
markLocalDeleted(man, 'z.txt') // 我这边删了 -> 记墓碑 (mySeq = 11)
|
||||
const mySeq = man.entries['z.txt'].mySeq
|
||||
assert(mySeq > 10, `墓碑序号不对: ${mySeq}`)
|
||||
// 对方拿一个**更早**的序号 (5) 声称文件还在
|
||||
const older = new Map<string, ManifestWireEntry>([['z.txt', {
|
||||
fileId: 'file-m6', relPath: 'z.txt', hash: H, size: 1, mtime: 0,
|
||||
state: 'present', seq: 5, deletedAt: 0,
|
||||
}]])
|
||||
mergePeer(man, older)
|
||||
const it = planSync(man, [], older).items.find(i => i.relPath === 'z.txt')!
|
||||
assert(it.action !== 'download', `墓碑失效, 文件被当成新增要下载: ${it.action}`)
|
||||
assert(it.action === 'delete-remote', `墓碑应该赢 (删除继续传播), 实际 ${it.action}`)
|
||||
// 对方用**更新**的序号声称恢复 -> 才允许复活
|
||||
const newer = new Map<string, ManifestWireEntry>([['z.txt', { ...older.get('z.txt')!, seq: mySeq + 1 }]])
|
||||
const it2 = planSync(man, [], newer).items.find(i => i.relPath === 'z.txt')!
|
||||
assert(it2.action === 'download', `对方序号更新应该允许恢复, 实际 ${it2.action}`)
|
||||
})
|
||||
|
||||
await runCase('M7 冲突: 两边都改了同一个文件 -> 判 conflict, apply 不会静默覆盖任一边', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
writeFileSync(join(A.syncRoot, 'shared.txt'), 'FROM-A-V2')
|
||||
writeFileSync(join(B.syncRoot, 'shared.txt'), 'FROM-B-V2')
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const it = scan.items.find(i => i.relPath === 'shared.txt')
|
||||
assert(!!it, 'shared.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'conflict', `两边都改过应判 conflict, 实际 ${it!.action}`)
|
||||
// apply 里没有 conflict 这个动作 (只能 skip) -> 两边都不该被覆盖
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'shared.txt', action: 'skip' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
assert(readText(join(A.syncRoot, 'shared.txt')) === 'FROM-A-V2', '本地被覆盖了')
|
||||
assert(readText(join(B.syncRoot, 'shared.txt')) === 'FROM-B-V2', '对方被覆盖了')
|
||||
})
|
||||
|
||||
await runCase('M8 多关系隔离: 同一个本地目录跟两个关系同步, 基线/墓碑互不串 (key 是 relationId)', async () => {
|
||||
needSetup()
|
||||
assert(!!relAE, '前置条件不成立: A-E 关系没建起来')
|
||||
const ctxB = syncCtx(A, B, relAB)
|
||||
const ctxE = syncCtx(A, E, relAE)
|
||||
// 专门用一个干净的文件测隔离 (iso.txt)
|
||||
writeFileSync(join(A.syncRoot, 'iso.txt'), 'ISO-CONTENT')
|
||||
// 先跟 E 同步一轮 -> E 这条关系也有 iso.txt 的基线
|
||||
const scanE = await scanSync(ctxE)
|
||||
assert(scanE.ok, `扫描失败: ${scanE.reason}`)
|
||||
const upE = scanE.items
|
||||
.filter(i => i.action === 'upload')
|
||||
.map(i => ({ relPath: i.relPath, action: 'upload' as const }))
|
||||
assert(upE.some(i => i.relPath === 'iso.txt'), 'E 关系里没有 iso.txt 的上传项')
|
||||
const rE = await applySyncItems({ ...ctxE, items: upE })
|
||||
assert(rE.ok, `同步失败: ${JSON.stringify(rE.results)}`)
|
||||
// 再跟 B 同步一轮 -> B 这条关系也有 iso.txt 的基线
|
||||
const scanB1 = await scanSync(ctxB)
|
||||
assert(scanB1.ok, `扫描失败: ${scanB1.reason}`)
|
||||
const upB = scanB1.items
|
||||
.filter(i => i.action === 'upload')
|
||||
.map(i => ({ relPath: i.relPath, action: 'upload' as const }))
|
||||
assert(upB.some(i => i.relPath === 'iso.txt'), 'B 关系里没有 iso.txt 的上传项')
|
||||
const rB = await applySyncItems({ ...ctxB, items: upB })
|
||||
assert(rB.ok, `同步失败: ${JSON.stringify(rB.results)}`)
|
||||
assert(!!A.manifestStore.loadManifest(relAE).entries['iso.txt']?.baseHash, 'E 关系里没有 iso.txt 的基线')
|
||||
assert(!!A.manifestStore.loadManifest(relAB).entries['iso.txt']?.baseHash, 'B 关系里没有 iso.txt 的基线')
|
||||
// 现在本地删掉 iso.txt, 只跟 B 这条关系同步
|
||||
rmSync(join(A.syncRoot, 'iso.txt'), { force: true })
|
||||
const scanB2 = await scanSync(ctxB)
|
||||
assert(scanB2.ok, `扫描失败: ${scanB2.reason}`)
|
||||
const it = scanB2.items.find(i => i.relPath === 'iso.txt')
|
||||
assert(!!it, 'iso.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it!.action}`)
|
||||
const r = await applySyncItems({ ...ctxB, items: [{ relPath: 'iso.txt', action: 'delete-remote' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
assert(!existsSync(join(B.syncRoot, 'iso.txt')), '对方那份还在')
|
||||
// B 关系的墓碑不能串到 E 关系上
|
||||
const manB = A.manifestStore.loadManifest(relAB)
|
||||
const manE = A.manifestStore.loadManifest(relAE)
|
||||
assert(manB.entries['iso.txt']?.myState === 'deleted', 'B 关系里没有墓碑')
|
||||
assert(manE.entries['iso.txt']?.myState !== 'deleted', 'E 关系被 B 的墓碑污染了')
|
||||
assert(manB.relationId !== manE.relationId, '两个关系用了同一个 key')
|
||||
})
|
||||
|
||||
await runCase('M9 目录穿越: 同步路径里带 ../ 必须被拒绝 (本地和远端都不许写到目录外)', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
// 本地侧: relPath 带 ../ -> 直接判非法路径, 一步都不该走
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: '../evil-escape.txt', action: 'upload' }] })
|
||||
assert(!r.ok, '本地穿越竟然成功了!')
|
||||
assert(!existsSync(join(dirname(A.syncRoot), 'evil-escape.txt')), '文件写到同步目录外面了!')
|
||||
// 远端侧: 绕过客户端, 直接打对方的 /sync/file
|
||||
const q = `token=${encodeURIComponent(ctx.token!)}&peerId=${encodeURIComponent(A.info.deviceId)}&relationId=${encodeURIComponent(relAB)}`
|
||||
const body = Buffer.from('EVIL')
|
||||
const up = await httpJson(B.info.filePort, `/sync/file?${q}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-file-rel-path': encodeURIComponent('../evil-escape.txt'),
|
||||
'x-file-size': String(body.length),
|
||||
'x-file-overwrite': '1',
|
||||
'Content-Length': String(body.length),
|
||||
},
|
||||
body,
|
||||
})
|
||||
assert(up.status !== 200, `远端上传穿越竟然成功了 (HTTP ${up.status})`)
|
||||
assert(!existsSync(join(dirname(B.syncRoot), 'evil-escape.txt')), '远端文件写到同步目录外面了!')
|
||||
// 下载方向也要挡住
|
||||
const dl = await httpJson(B.info.filePort, `/sync/file?${q}&path=${encodeURIComponent('../evil-escape.txt')}`)
|
||||
assert(dl.status !== 200, `远端下载穿越竟然成功了 (HTTP ${dl.status})`)
|
||||
})
|
||||
|
||||
await runCase('M10 幂等: 同一批变更连续 apply 两次, 结果不变, 不产生重复副作用', async () => {
|
||||
needSetup()
|
||||
const ctx = syncCtx(A, B, relAB)
|
||||
writeFileSync(join(A.syncRoot, 'idem.txt'), 'IDEMPOTENT')
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const it = scan.items.find(i => i.relPath === 'idem.txt')
|
||||
assert(!!it, 'idem.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'upload', `新文件应判 upload, 实际 ${it!.action}`)
|
||||
const clockBefore = A.manifestStore.loadManifest(relAB).clock
|
||||
const r1 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'upload' }] })
|
||||
assert(r1.ok, `第一次同步失败: ${JSON.stringify(r1.results)}`)
|
||||
const r2 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'upload' }] })
|
||||
assert(r2.ok, `第二次同步失败: ${JSON.stringify(r2.results)}`)
|
||||
// 结果不变: 内容一样, 没有多出副本, 关系表时钟也没动
|
||||
assert(readText(join(B.syncRoot, 'idem.txt')) === 'IDEMPOTENT', '对方内容不对')
|
||||
const copies = readdirSync(B.syncRoot).filter(n => n.includes('idem'))
|
||||
assert(copies.length === 1, `对方多出了副本: ${copies.join(',')}`)
|
||||
const clockAfter = A.manifestStore.loadManifest(relAB).clock
|
||||
assert(clockAfter === clockBefore, `重复 apply 让关系表时钟前进了: ${clockBefore} -> ${clockAfter}`)
|
||||
// 再扫一次: 应该是 same, 不该再上传
|
||||
const it2 = (await scanSync(ctx)).items.find(i => i.relPath === 'idem.txt')
|
||||
assert(!!it2 && it2.action === 'same', `重复 apply 后还在上传: ${it2?.action}`)
|
||||
// 删除也一样幂等
|
||||
rmSync(join(A.syncRoot, 'idem.txt'), { force: true })
|
||||
const it3 = (await scanSync(ctx)).items.find(i => i.relPath === 'idem.txt')
|
||||
assert(!!it3 && it3.action === 'delete-remote', `本地删了应判 delete-remote, 实际 ${it3?.action}`)
|
||||
const d1 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'delete-remote' }] })
|
||||
assert(d1.ok, `第一次删除失败: ${JSON.stringify(d1.results)}`)
|
||||
const d2 = await applySyncItems({ ...ctx, items: [{ relPath: 'idem.txt', action: 'delete-remote' }] })
|
||||
assert(d2.ok, `重复删除失败: ${JSON.stringify(d2.results)}`)
|
||||
assert(!existsSync(join(B.syncRoot, 'idem.txt')), '对方那份还在')
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 文件夹同步 ============
|
||||
if (want('sync')) {
|
||||
console.log('\n\x1b[1m[文件夹同步]\x1b[0m')
|
||||
// 同步区是**独立于网盘**的一套空间 (/sync/*): 要有关系里的 sync 授权
|
||||
const Y1 = new Node('peer-y1111111-0000-4000-8000-0000000000y1', '同步端Y1')
|
||||
const Y2 = new Node('peer-y2222222-0000-4000-8000-0000000000y2', '同步端Y2')
|
||||
await Y1.start()
|
||||
await Y2.start()
|
||||
await link(Y1, Y2)
|
||||
// 前置: 建一条同步关系 (同步区走 /sync/*, 没关系连不上)
|
||||
// 注意顺序: **先建关系再放文件** —— 实际同步根 = 顶层目录 + 关系子目录,
|
||||
// 关系没建好时子目录还没分配, 文件放错地方就"凭空消失"了。
|
||||
let yRelId = ''
|
||||
let ySetupErr = ''
|
||||
try {
|
||||
yRelId = (await establishRelation(Y1, Y2)).relationId
|
||||
} catch (e: any) {
|
||||
ySetupErr = String(e?.message || e)
|
||||
}
|
||||
mkdirSync(join(Y1.syncRoot, 'sub'), { recursive: true })
|
||||
writeFileSync(join(Y1.syncRoot, 'same.txt'), 'SAME-CONTENT')
|
||||
writeFileSync(join(Y1.syncRoot, 'sub', 'deep.txt'), 'DEEP-CONTENT')
|
||||
writeFileSync(join(Y2.syncRoot, 'only-remote.txt'), 'REMOTE-ONLY')
|
||||
const yCtx = (): SyncContext => {
|
||||
assert(!!yRelId, `前置条件不成立: 同步关系没建起来 (${ySetupErr})`)
|
||||
return syncCtx(Y1, Y2, yRelId)
|
||||
}
|
||||
|
||||
await runCase('Y1 本地扫描: 相对路径 / 大小 / hash 正确', async () => {
|
||||
const r = scanLocalDir(Y1.syncRoot)
|
||||
assert(r.ok, `扫描失败: ${r.reason}`)
|
||||
const names = r.files.map(f => f.relPath)
|
||||
assert(names.includes('same.txt'), `缺 same.txt: ${names.join(',')}`)
|
||||
assert(names.includes('sub/deep.txt'), `缺深层文件: ${names.join(',')}`)
|
||||
const f = r.files.find(x => x.relPath === 'same.txt')!
|
||||
assert(f.size === 'SAME-CONTENT'.length, `大小不对: ${f.size}`)
|
||||
assert(!!f.hash && f.hash.length === 64, `hash 不对: ${f.hash}`)
|
||||
})
|
||||
|
||||
await runCase('Y2 三方对比: 只有一边有 / 内容一致 / 都不对', async () => {
|
||||
const local = scanLocalDir(Y1.syncRoot).files
|
||||
const same = local.find(f => f.relPath === 'same.txt')!
|
||||
const man: Manifest = { relationId: 'y-unit', clock: 0, remoteSeq: 0, entries: {} }
|
||||
reconcileLocal(man, local) // 先把本地现状对齐进关系表 (跟 scanSync 里做的一样)
|
||||
const peer = new Map<string, ManifestWireEntry>([
|
||||
['same.txt', { fileId: 'p1', relPath: 'same.txt', hash: same.hash!, size: same.size, mtime: same.mtime, state: 'present', seq: 1, deletedAt: 0 }],
|
||||
['only-remote.txt', { fileId: 'p2', relPath: 'only-remote.txt', hash: 'b'.repeat(64), size: 10, mtime: Date.now(), state: 'present', seq: 1, deletedAt: 0 }],
|
||||
])
|
||||
const { items, stats } = planSync(man, local, peer)
|
||||
const get = (p: string) => items.find(i => i.relPath === p)!
|
||||
assert(get('same.txt').action === 'same', `same.txt 判成了 ${get('same.txt').action}`)
|
||||
assert(get('only-remote.txt').action === 'download', `only-remote 判成了 ${get('only-remote.txt').action}`)
|
||||
assert(get('sub/deep.txt').action === 'upload', `deep.txt 判成了 ${get('sub/deep.txt').action}`)
|
||||
assert(stats.same === 1 && stats.download === 1 && stats.upload === 1,
|
||||
`统计不对: ${JSON.stringify(stats)}`)
|
||||
})
|
||||
|
||||
await runCase('Y3 首次同步没有基线: 内容不同且修改时间接近 -> 判 conflict (不静默覆盖)', async () => {
|
||||
const now = Date.now()
|
||||
const local: SyncFileInfo[] = [{ relPath: 'c.txt', size: 3, mtime: now, hash: 'a'.repeat(64) }]
|
||||
const man: Manifest = { relationId: 'y-unit3', clock: 0, remoteSeq: 0, entries: {} }
|
||||
reconcileLocal(man, local)
|
||||
const peer = new Map<string, ManifestWireEntry>([['c.txt', {
|
||||
fileId: 'p', relPath: 'c.txt', hash: 'b'.repeat(64), size: 4, mtime: now + 500,
|
||||
state: 'present', seq: 1, deletedAt: 0,
|
||||
}]])
|
||||
const { items, stats } = planSync(man, local, peer)
|
||||
assert(items[0].action === 'conflict', `判成了 ${items[0].action}`)
|
||||
assert(stats.conflict === 1, `冲突数不对: ${stats.conflict}`)
|
||||
})
|
||||
|
||||
await runCase('Y4 首次同步没有基线: 谁更新就往谁的方向同步 (mtime 兜底)', async () => {
|
||||
const now = Date.now()
|
||||
const localNew: SyncFileInfo[] = [{ relPath: 'x.txt', size: 3, mtime: now + 60_000, hash: 'a'.repeat(64) }]
|
||||
const remoteOld: SyncFileInfo[] = [{ relPath: 'x.txt', size: 4, mtime: now, hash: 'b'.repeat(64) }]
|
||||
const mk = (files: SyncFileInfo[]): Manifest => {
|
||||
const man: Manifest = { relationId: 'y-unit4', clock: 0, remoteSeq: 0, entries: {} }
|
||||
reconcileLocal(man, files)
|
||||
return man
|
||||
}
|
||||
const peerOld = new Map<string, ManifestWireEntry>([['x.txt', {
|
||||
fileId: 'p', relPath: 'x.txt', hash: remoteOld[0].hash!, size: 4, mtime: remoteOld[0].mtime,
|
||||
state: 'present', seq: 1, deletedAt: 0,
|
||||
}]])
|
||||
const peerNew = new Map<string, ManifestWireEntry>([['x.txt', {
|
||||
fileId: 'p', relPath: 'x.txt', hash: localNew[0].hash!, size: 3, mtime: localNew[0].mtime,
|
||||
state: 'present', seq: 1, deletedAt: 0,
|
||||
}]])
|
||||
assert(planSync(mk(localNew), localNew, peerOld).items[0].action === 'upload', '本地新应判 upload')
|
||||
assert(planSync(mk(remoteOld), remoteOld, peerNew).items[0].action === 'download', '对方新应判 download')
|
||||
})
|
||||
|
||||
await runCase('Y5 端到端: 上传的文件真的落到对方的同步区, 内容一致', async () => {
|
||||
const ctx = yCtx()
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'sub/deep.txt', action: 'upload' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
const landed = join(Y2.syncRoot, 'sub', 'deep.txt')
|
||||
assert(existsSync(landed), `文件没落到对方的同步区: ${landed}`)
|
||||
assert(readText(landed) === 'DEEP-CONTENT', '内容不对')
|
||||
})
|
||||
|
||||
await runCase('Y6 端到端: 下载对方的文件到本地', async () => {
|
||||
const ctx = yCtx()
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'only-remote.txt', action: 'download' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
const landed = join(Y1.syncRoot, 'only-remote.txt')
|
||||
assert(existsSync(landed), `文件没下载到本地: ${landed}`)
|
||||
assert(readText(landed) === 'REMOTE-ONLY', '内容不对')
|
||||
})
|
||||
|
||||
await runCase('Y7 建立基线后本地改了文件: 判 upload (不是 conflict) -> 同步后对方也更新', async () => {
|
||||
const ctx = yCtx()
|
||||
// 先建立基线: 扫一次 + 同步一次 (双方都认下这个文件)
|
||||
const base = await scanSync(ctx)
|
||||
assert(base.ok, `扫描失败: ${base.reason}`)
|
||||
const baseItems = base.items
|
||||
.filter(i => i.action === 'upload' || i.action === 'download')
|
||||
.map(i => ({ relPath: i.relPath, action: i.action as 'upload' | 'download' }))
|
||||
const rb = await applySyncItems({ ...ctx, items: baseItems })
|
||||
assert(rb.ok, `建立基线失败: ${JSON.stringify(rb.results)}`)
|
||||
// 基线建立后再扫一次应该是 same
|
||||
const again = await scanSync(ctx)
|
||||
const sameIt = again.items.find(i => i.relPath === 'same.txt')
|
||||
assert(!!sameIt && sameIt.action === 'same', `建立基线后应该判 same, 实际 ${sameIt?.action}`)
|
||||
// 现在改本地文件 -> 只有本地改过 -> upload
|
||||
writeFileSync(join(Y1.syncRoot, 'same.txt'), 'LOCAL-EDITED-V2')
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const it = scan.items.find(i => i.relPath === 'same.txt')
|
||||
assert(!!it, 'same.txt 没出现在扫描结果里')
|
||||
assert(it!.action === 'upload', `本地改过应判 upload, 实际 ${it!.action}`)
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: 'same.txt', action: 'upload' }] })
|
||||
assert(r.ok, `同步失败: ${JSON.stringify(r.results)}`)
|
||||
assert(readText(join(Y2.syncRoot, 'same.txt')) === 'LOCAL-EDITED-V2', '对方同步区没被更新')
|
||||
})
|
||||
|
||||
await runCase('Y8 目录穿越: 上传时 rel-path 带 ../ 必须被拒绝', async () => {
|
||||
const ctx = yCtx()
|
||||
assert(!existsSync(join(dirname(Y2.syncRoot), 'evil-escape.txt')), '测试前不该有同名文件')
|
||||
const r = await applySyncItems({ ...ctx, items: [{ relPath: '../evil-escape.txt', action: 'upload' }] })
|
||||
assert(!r.ok, '穿越竟然成功了!')
|
||||
assert(!existsSync(join(dirname(Y2.syncRoot), 'evil-escape.txt')), '文件写到同步目录外面了!')
|
||||
})
|
||||
|
||||
await runCase('Y9 token 不对 -> 对方拒绝 (403)', async () => {
|
||||
const bad = { ...Y2.info, driveToken: 'wrong-token-sync' }
|
||||
const scan = await scanSync({
|
||||
peer: bad, localDir: Y1.syncLocalDir, remotePath: '',
|
||||
relationId: yRelId, store: Y1.manifestStore,
|
||||
})
|
||||
assert(!scan.ok, '错 token 竟然扫成功了!')
|
||||
assert(/令牌/.test(scan.reason || ''), `错误信息不对: ${scan.reason}`)
|
||||
})
|
||||
|
||||
await runCase('Y10 扫描对方目录: 拿到递归清单和 hash', async () => {
|
||||
const ctx = yCtx()
|
||||
const scan = await scanSync(ctx)
|
||||
assert(scan.ok, `扫描失败: ${scan.reason}`)
|
||||
const names = scan.items.map(i => i.relPath)
|
||||
assert(names.includes('same.txt'), `缺 same.txt: ${names.join(',')}`)
|
||||
assert(names.includes('sub/deep.txt'), `缺深层文件: ${names.join(',')}`)
|
||||
const same = scan.items.find(i => i.relPath === 'same.txt')!
|
||||
assert(same.remote && !!same.remote.hash, '远端没有 hash')
|
||||
assert(same.local && !!same.local.hash, '本地没有 hash')
|
||||
})
|
||||
|
||||
await Y1.stop()
|
||||
await Y2.stop()
|
||||
}
|
||||
|
||||
// ============ 审计 ============
|
||||
if (want('audit')) {
|
||||
console.log('\n\x1b[1m[审计]\x1b[0m')
|
||||
@@ -782,6 +1518,9 @@ async function main() {
|
||||
// ============ 汇总 ============
|
||||
await A.stop()
|
||||
await B.stop()
|
||||
await E.stop()
|
||||
await F.stop()
|
||||
await G.stop()
|
||||
|
||||
const pass = results.filter(r => r.ok).length
|
||||
const fail = results.length - pass
|
||||
|
||||
@@ -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