@filen/sync 0.2.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.node-version +1 -1
- package/dist/ignorer.d.ts +6 -0
- package/dist/ignorer.js +43 -24
- package/dist/ignorer.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/deltas.d.ts +58 -2
- package/dist/lib/deltas.js +693 -108
- package/dist/lib/deltas.js.map +1 -1
- package/dist/lib/environment.d.ts +47 -0
- package/dist/lib/environment.js +71 -0
- package/dist/lib/environment.js.map +1 -0
- package/dist/lib/filesystems/dirTree.d.ts +70 -0
- package/dist/lib/filesystems/dirTree.js +157 -0
- package/dist/lib/filesystems/dirTree.js.map +1 -0
- package/dist/lib/filesystems/local.d.ts +18 -8
- package/dist/lib/filesystems/local.js +166 -160
- package/dist/lib/filesystems/local.js.map +1 -1
- package/dist/lib/filesystems/remote.d.ts +12 -5
- package/dist/lib/filesystems/remote.js +226 -172
- package/dist/lib/filesystems/remote.js.map +1 -1
- package/dist/lib/ipc.js +1 -2
- package/dist/lib/ipc.js.map +1 -1
- package/dist/lib/lock.js +19 -12
- package/dist/lib/lock.js.map +1 -1
- package/dist/lib/logger.js +9 -7
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/state.js +159 -63
- package/dist/lib/state.js.map +1 -1
- package/dist/lib/sync.d.ts +18 -0
- package/dist/lib/sync.js +165 -96
- package/dist/lib/sync.js.map +1 -1
- package/dist/lib/tasks.d.ts +7 -8
- package/dist/lib/tasks.js +38 -45
- package/dist/lib/tasks.js.map +1 -1
- package/dist/semaphore.d.ts +1 -0
- package/dist/semaphore.js +22 -5
- package/dist/semaphore.js.map +1 -1
- package/dist/utils.js +51 -35
- package/dist/utils.js.map +1 -1
- package/eslint.config.mjs +36 -0
- package/package.json +19 -15
- package/tests/bench/collapse.bench.ts +114 -0
- package/tests/bench/cycle.bench.ts +111 -0
- package/tests/bench/deltas.bench.ts +151 -0
- package/tests/bench/harness/fake-sync.ts +32 -0
- package/tests/bench/harness/measure.ts +276 -0
- package/tests/bench/harness/scale-world.ts +160 -0
- package/tests/bench/harness/trees.ts +275 -0
- package/tests/bench/local-scan.bench.ts +74 -0
- package/tests/bench/longrun.bench.ts +130 -0
- package/tests/bench/profile-incremental.ts +90 -0
- package/tests/bench/remote-build.bench.ts +104 -0
- package/tests/bench/render.ts +14 -0
- package/tests/bench/semaphore.bench.ts +79 -0
- package/tests/bench/state.bench.ts +85 -0
- package/tests/bench/tasks-dispatch.bench.ts +156 -0
- package/tests/conformance/virtual-fs.test.ts +213 -0
- package/tests/e2e/backup.e2e.test.ts +130 -0
- package/tests/e2e/confirm.e2e.test.ts +191 -0
- package/tests/e2e/conflict.e2e.test.ts +261 -0
- package/tests/e2e/edge.e2e.test.ts +339 -0
- package/tests/e2e/harness/account.ts +104 -0
- package/tests/e2e/harness/assert.ts +127 -0
- package/tests/e2e/harness/drive.ts +88 -0
- package/tests/e2e/harness/mutations.ts +249 -0
- package/tests/e2e/harness/world.ts +222 -0
- package/tests/e2e/ignore.e2e.test.ts +123 -0
- package/tests/e2e/lifecycle.e2e.test.ts +290 -0
- package/tests/e2e/modes.e2e.test.ts +215 -0
- package/tests/e2e/platform.e2e.test.ts +157 -0
- package/tests/e2e/property.e2e.test.ts +163 -0
- package/tests/e2e/races.e2e.test.ts +90 -0
- package/tests/e2e/regressions.e2e.test.ts +212 -0
- package/tests/e2e/resilience.e2e.test.ts +231 -0
- package/tests/e2e/special.e2e.test.ts +185 -0
- package/tests/e2e/state.e2e.test.ts +229 -0
- package/tests/e2e/sync.e2e.test.ts +222 -0
- package/tests/fakes/fake-cloud.test.ts +267 -0
- package/tests/fakes/fake-cloud.ts +1094 -0
- package/tests/fakes/virtual-fs.ts +354 -0
- package/tests/harness/known-bug.ts +17 -0
- package/tests/harness/mutations.ts +65 -0
- package/tests/harness/runner.ts +141 -0
- package/tests/harness/snapshot.ts +113 -0
- package/tests/harness/world.ts +187 -0
- package/tests/scenarios/a-baseline.test.ts +107 -0
- package/tests/scenarios/aa-races.test.ts +258 -0
- package/tests/scenarios/ab-mode-property.test.ts +189 -0
- package/tests/scenarios/ac-platform.test.ts +320 -0
- package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
- package/tests/scenarios/b-additions.test.ts +160 -0
- package/tests/scenarios/c-modifications.test.ts +194 -0
- package/tests/scenarios/d-deletions.test.ts +259 -0
- package/tests/scenarios/e-rename-move.test.ts +288 -0
- package/tests/scenarios/f-ignore-filter.test.ts +346 -0
- package/tests/scenarios/g-large-deletion.test.ts +277 -0
- package/tests/scenarios/h-resilience.test.ts +167 -0
- package/tests/scenarios/i-lifecycle.test.ts +353 -0
- package/tests/scenarios/j-state-cache.test.ts +264 -0
- package/tests/scenarios/k-scale.test.ts +202 -0
- package/tests/scenarios/l-property.test.ts +145 -0
- package/tests/scenarios/m-golden.test.ts +452 -0
- package/tests/scenarios/o-task-errors.test.ts +497 -0
- package/tests/scenarios/p-remote-originated.test.ts +306 -0
- package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
- package/tests/scenarios/r-rename-stress.test.ts +208 -0
- package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
- package/tests/scenarios/t-type-change.test.ts +144 -0
- package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
- package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
- package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
- package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
- package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
- package/tests/scenarios/z-cross-ops.test.ts +285 -0
- package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
- package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
- package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
- package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
- package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
- package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
- package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
- package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
- package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
- package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
- package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
- package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
- package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
- package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
- package/tests/unit/collapse-deltas.test.ts +276 -0
- package/tests/unit/dir-tree.test.ts +159 -0
- package/tests/unit/icloud.test.ts +115 -0
- package/tests/unit/ignorer-cache-regression.test.ts +70 -0
- package/tests/unit/ignorer.test.ts +63 -0
- package/tests/unit/ipc-lock.test.ts +438 -0
- package/tests/unit/lock.test.ts +135 -0
- package/tests/unit/n-unit.test.ts +632 -0
- package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
- package/tests/unit/semaphore-regression.test.ts +140 -0
- package/tests/unit/state-refencode-regression.test.ts +224 -0
- package/tests/unit/state.test.ts +809 -0
- package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
- package/tests/unit/worker-api.test.ts +379 -0
- package/tsconfig.json +10 -1
- package/tsconfig.test.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.bench.config.ts +32 -0
- package/vitest.config.ts +27 -0
- package/vitest.e2e.config.ts +68 -0
- package/.eslintrc +0 -16
- package/jest.config.js +0 -5
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, localMutate, remoteMutate, type Step } from "../harness/runner"
|
|
3
|
+
import { BASE_TIME } from "../harness/world"
|
|
4
|
+
import { allOps } from "../harness/snapshot"
|
|
5
|
+
import { writeLocalAt, rmLocal } from "../harness/mutations"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Category AB — randomized property tests for the directional MIRROR modes (localToCloud / cloudToLocal),
|
|
9
|
+
* the directional analogue of Category L (twoWay). The defining invariant of a mirror is stronger than
|
|
10
|
+
* twoWay convergence: the AUTHORITATIVE side dictates the whole tree, so no matter what foreign NOISE the
|
|
11
|
+
* other side injects (adds, edits, deletes), the engine must drive the two worlds back to the
|
|
12
|
+
* authoritative side's exact state and stay there (idempotence). This exercises mirror-delete + F5
|
|
13
|
+
* (authoritative wins) + F6 (foreign-edit revert) together under thousands of random operations.
|
|
14
|
+
*
|
|
15
|
+
* Histories are well-behaved on the authoritative side (strictly-increasing whole-second mtimes, one op
|
|
16
|
+
* per path per round, non-empty content, case-distinct names) so a failure is a real bug. The foreign
|
|
17
|
+
* noise is deliberately adversarial.
|
|
18
|
+
*/
|
|
19
|
+
function mulberry32(seed: number): () => number {
|
|
20
|
+
let state = seed >>> 0
|
|
21
|
+
|
|
22
|
+
return () => {
|
|
23
|
+
state = (state + 0x6d2b79f5) >>> 0
|
|
24
|
+
|
|
25
|
+
let t = Math.imul(state ^ (state >>> 15), 1 | state)
|
|
26
|
+
|
|
27
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
28
|
+
|
|
29
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const FILE_POOL = ["f0.txt", "f1.txt", "f2.txt", "f3.txt", "f4.txt"]
|
|
34
|
+
const ROUNDS = 8
|
|
35
|
+
const MAX_OPS_PER_ROUND = 3
|
|
36
|
+
const SETTLE_CYCLES = 4
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build a directional-mirror history. `side` is the authoritative side: its mutations are the source of
|
|
40
|
+
* truth, and we additionally inject foreign noise on the OTHER side that the mirror must undo. Returns
|
|
41
|
+
* the step list. After settling, the two worlds must be identical.
|
|
42
|
+
*/
|
|
43
|
+
function buildMirrorHistory(seed: number, authoritative: "local" | "remote"): Step[] {
|
|
44
|
+
const random = mulberry32(seed)
|
|
45
|
+
const pick = <T>(items: readonly T[]): T => items[Math.floor(random() * items.length)]!
|
|
46
|
+
const steps: Step[] = []
|
|
47
|
+
const exists = new Set<string>()
|
|
48
|
+
let clock = BASE_TIME + 1000
|
|
49
|
+
|
|
50
|
+
const authWrite = (path: string, content: string, mtime: number): void => {
|
|
51
|
+
if (authoritative === "local") {
|
|
52
|
+
steps.push(localMutate(world => writeLocalAt(world, path, content, mtime)))
|
|
53
|
+
} else {
|
|
54
|
+
steps.push(remoteMutate(world => world.cloud.controls.addFile(`/${path}`, content, { mtimeMs: mtime })))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const authUpdate = (path: string, content: string, mtime: number): void => {
|
|
58
|
+
if (authoritative === "local") {
|
|
59
|
+
steps.push(localMutate(world => writeLocalAt(world, path, content, mtime)))
|
|
60
|
+
} else {
|
|
61
|
+
steps.push(remoteMutate(world => world.cloud.controls.updateFile(`/${path}`, content, { mtimeMs: mtime })))
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const authDelete = (path: string): void => {
|
|
65
|
+
if (authoritative === "local") {
|
|
66
|
+
steps.push(localMutate(world => rmLocal(world, path)))
|
|
67
|
+
} else {
|
|
68
|
+
steps.push(remoteMutate(world => world.cloud.controls.trashPath(`/${path}`)))
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Foreign noise lands on the NON-authoritative side; the mirror must erase its effect. `noiseClock` is
|
|
72
|
+
// captured by VALUE per call — the closures must NOT close over the mutable `clock` (it advances during
|
|
73
|
+
// generation, so a by-reference capture would stamp every foreign edit with the final, largest mtime and
|
|
74
|
+
// collapse them all into one whole-second, the §C11 ambiguity this suite deliberately avoids).
|
|
75
|
+
const foreignNoise = (round: number, noiseClock: number): void => {
|
|
76
|
+
const r = random()
|
|
77
|
+
|
|
78
|
+
if (authoritative === "local") {
|
|
79
|
+
if (r < 0.4) {
|
|
80
|
+
steps.push(remoteMutate(world => world.cloud.controls.addFile(`/foreign-${round}.txt`, `foreign${round}`, { mtimeMs: noiseClock })))
|
|
81
|
+
} else if (r < 0.7 && exists.size > 0) {
|
|
82
|
+
const target = pick([...exists])
|
|
83
|
+
|
|
84
|
+
steps.push(remoteMutate(world => world.cloud.controls.updateFile(`/${target}`, `tampered${round}`, { mtimeMs: noiseClock })))
|
|
85
|
+
} else if (exists.size > 0) {
|
|
86
|
+
const target = pick([...exists])
|
|
87
|
+
|
|
88
|
+
steps.push(remoteMutate(world => world.cloud.controls.trashPath(`/${target}`)))
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
if (r < 0.4) {
|
|
92
|
+
steps.push(localMutate(world => writeLocalAt(world, `foreign-${round}.txt`, `foreign${round}`, noiseClock)))
|
|
93
|
+
} else if (r < 0.7 && exists.size > 0) {
|
|
94
|
+
const target = pick([...exists])
|
|
95
|
+
|
|
96
|
+
steps.push(localMutate(world => writeLocalAt(world, target, `tampered${round}`, noiseClock)))
|
|
97
|
+
} else if (exists.size > 0) {
|
|
98
|
+
const target = pick([...exists])
|
|
99
|
+
|
|
100
|
+
steps.push(localMutate(world => rmLocal(world, target)))
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Seed one authoritative file.
|
|
106
|
+
clock += 1000
|
|
107
|
+
authWrite("f0.txt", "seed-0", clock)
|
|
108
|
+
exists.add("f0.txt")
|
|
109
|
+
steps.push(runCycle())
|
|
110
|
+
|
|
111
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
112
|
+
const touched = new Set<string>()
|
|
113
|
+
const ops = 1 + Math.floor(random() * MAX_OPS_PER_ROUND)
|
|
114
|
+
|
|
115
|
+
for (let op = 0; op < ops; op++) {
|
|
116
|
+
const path = pick(FILE_POOL)
|
|
117
|
+
|
|
118
|
+
if (touched.has(path)) {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
touched.add(path)
|
|
123
|
+
clock += 1000
|
|
124
|
+
|
|
125
|
+
const content = `s${seed}-r${round}-o${op}-${Math.floor(random() * 1_000_000)}`
|
|
126
|
+
const doDelete = exists.has(path) && random() < 0.3
|
|
127
|
+
|
|
128
|
+
if (doDelete) {
|
|
129
|
+
exists.delete(path)
|
|
130
|
+
authDelete(path)
|
|
131
|
+
} else if (exists.has(path)) {
|
|
132
|
+
authUpdate(path, content, clock)
|
|
133
|
+
} else {
|
|
134
|
+
exists.add(path)
|
|
135
|
+
authWrite(path, content, clock)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Inject adversarial foreign noise most rounds.
|
|
140
|
+
if (random() < 0.7) {
|
|
141
|
+
clock += 1000
|
|
142
|
+
foreignNoise(round, clock)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
steps.push(runCycle())
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (let cycle = 0; cycle < SETTLE_CYCLES; cycle++) {
|
|
149
|
+
steps.push(runCycle())
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return steps
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("Category AB — directional mirror property tests", () => {
|
|
156
|
+
const ITERATIONS = 16
|
|
157
|
+
|
|
158
|
+
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
|
|
159
|
+
const seed = 0x5151 + iteration * 0x9e37
|
|
160
|
+
|
|
161
|
+
it(`AB-localToCloud seed=${seed}: the remote is driven to exactly mirror the authoritative local tree`, async () => {
|
|
162
|
+
const steps = buildMirrorHistory(seed, "local")
|
|
163
|
+
const result = await runScenario({ name: `AB-ltc-${seed}`, mode: "localToCloud", steps })
|
|
164
|
+
|
|
165
|
+
// Mirror: the remote equals the authoritative local tree, every foreign edit undone.
|
|
166
|
+
expect(result.finalRemote, `seed=${seed} remote did not mirror local`).toEqual(result.finalLocal)
|
|
167
|
+
|
|
168
|
+
// Idempotence: the final settled cycle did no transfers.
|
|
169
|
+
const lastCycle = result.cycles[result.cycles.length - 1]!
|
|
170
|
+
|
|
171
|
+
expect(allOps(lastCycle.messages), `seed=${seed} not idempotent`).toEqual([])
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (let iteration = 0; iteration < ITERATIONS; iteration++) {
|
|
176
|
+
const seed = 0x7333 + iteration * 0x9e37
|
|
177
|
+
|
|
178
|
+
it(`AB-cloudToLocal seed=${seed}: local is driven to exactly mirror the authoritative remote tree`, async () => {
|
|
179
|
+
const steps = buildMirrorHistory(seed, "remote")
|
|
180
|
+
const result = await runScenario({ name: `AB-ctl-${seed}`, mode: "cloudToLocal", steps })
|
|
181
|
+
|
|
182
|
+
expect(result.finalLocal, `seed=${seed} local did not mirror remote`).toEqual(result.finalRemote)
|
|
183
|
+
|
|
184
|
+
const lastCycle = result.cycles[result.cycles.length - 1]!
|
|
185
|
+
|
|
186
|
+
expect(allOps(lastCycle.messages), `seed=${seed} not idempotent`).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
})
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle, restart } from "../harness/runner"
|
|
3
|
+
import { messagesOfType, hadTransfers } from "../harness/snapshot"
|
|
4
|
+
import { type SyncMessage } from "../../src/types"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Category AC — cross-platform / per-OS path rules (behavioral spec §platform). A production sync
|
|
8
|
+
* engine runs on Windows, macOS and Linux against ONE shared backend, so a name that is perfectly
|
|
9
|
+
* legal where it was created can be illegal on the machine syncing it down. The engine guards this in
|
|
10
|
+
* BOTH filesystem walks via `isValidPath` / `isPathOverMaxLength` / `isNameOverMaxLength` (utils.ts),
|
|
11
|
+
* each of which reads `process.platform` at call time. The rule:
|
|
12
|
+
*
|
|
13
|
+
* - win32 → forbids `< > : " | ? *`, control chars, the reserved device names (CON/PRN/AUX/NUL/
|
|
14
|
+
* COM1-9/LPT1-9, with or without an extension), and paths over 512 chars.
|
|
15
|
+
* - darwin → forbids `:` and NUL; paths over 1024 chars.
|
|
16
|
+
* - linux → forbids only NUL; paths over 4096 chars.
|
|
17
|
+
*
|
|
18
|
+
* A path the local platform can't represent must be SKIPPED (reason `invalidPath` / `pathLength` /
|
|
19
|
+
* `nameLength`) — never downloaded, never crashing the cycle, and (crucially) never deleted from the
|
|
20
|
+
* side that legitimately holds it. The opposite OS, where the name is legal, must sync it normally.
|
|
21
|
+
*
|
|
22
|
+
* These tests force `process.platform` so every branch runs deterministically on ANY host — darwin
|
|
23
|
+
* locally, and all three runners in CI. The host's real platform is irrelevant to the assertions.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Run an entire scenario with `process.platform` forced to `platform`, restoring it afterwards. */
|
|
27
|
+
async function withPlatform<T>(platform: NodeJS.Platform, fn: () => Promise<T>): Promise<T> {
|
|
28
|
+
const original = Object.getOwnPropertyDescriptor(process, "platform")!
|
|
29
|
+
|
|
30
|
+
Object.defineProperty(process, "platform", { value: platform, configurable: true })
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
return await fn()
|
|
34
|
+
} finally {
|
|
35
|
+
Object.defineProperty(process, "platform", original)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every structural-ignore reason reported across the whole message stream (local + remote walks). */
|
|
40
|
+
function ignoredReasons(messages: SyncMessage[]): string[] {
|
|
41
|
+
const local = messagesOfType(messages, "localTreeIgnored").flatMap(message => message.data.ignored.map(entry => entry.reason))
|
|
42
|
+
const remote = messagesOfType(messages, "remoteTreeIgnored").flatMap(message => message.data.ignored.map(entry => entry.reason))
|
|
43
|
+
|
|
44
|
+
return [...local, ...remote]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A path long enough that its absolute local form (prefixed with "/local") exceeds Windows' 512-char
|
|
48
|
+
// limit, but every individual NAME stays under the uniform 255-char name limit (so only `pathLength`,
|
|
49
|
+
// not `nameLength`, is exercised). 6 + 1 + 200 + 1 + 200 + 1 + 154 = 563 chars absolute.
|
|
50
|
+
const LONG_DIR = "d".repeat(200)
|
|
51
|
+
const LONG_SUB = "s".repeat(200)
|
|
52
|
+
const LONG_LEAF = `${"f".repeat(150)}.txt`
|
|
53
|
+
const LONG_PATH = `/${LONG_DIR}/${LONG_SUB}/${LONG_LEAF}`
|
|
54
|
+
|
|
55
|
+
describe("Category AC — cross-platform path rules", () => {
|
|
56
|
+
it("AC1: win32 skips a remote file with a colon (invalidPath), syncs valid siblings, never deletes it", async () => {
|
|
57
|
+
const result = await withPlatform("win32", () =>
|
|
58
|
+
runScenario({
|
|
59
|
+
name: "AC1",
|
|
60
|
+
mode: "twoWay",
|
|
61
|
+
initialRemote: {
|
|
62
|
+
"/report:v2.txt": "colon is illegal on windows",
|
|
63
|
+
"/ok.txt": "fine everywhere"
|
|
64
|
+
},
|
|
65
|
+
steps: [runCycle(), runCycle()]
|
|
66
|
+
})
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
// The colon file is skipped on the way down…
|
|
70
|
+
expect(result.finalLocal["/report:v2.txt"]).toBeUndefined()
|
|
71
|
+
expect(ignoredReasons(result.messages)).toContain("invalidPath")
|
|
72
|
+
// …the valid sibling converges on both sides…
|
|
73
|
+
expect(result.finalLocal["/ok.txt"]).toMatchObject({ type: "file" })
|
|
74
|
+
expect(result.finalRemote["/ok.txt"]).toMatchObject({ type: "file" })
|
|
75
|
+
// …and the skipped remote file is NOT deleted (ignore ≠ delete; it just can't land on win32).
|
|
76
|
+
expect(result.finalRemote["/report:v2.txt"]).toMatchObject({ type: "file" })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("AC2: linux DOES sync the colon file down — a colon is a legal filename on linux", async () => {
|
|
80
|
+
const result = await withPlatform("linux", () =>
|
|
81
|
+
runScenario({
|
|
82
|
+
name: "AC2",
|
|
83
|
+
mode: "twoWay",
|
|
84
|
+
initialRemote: {
|
|
85
|
+
"/report:v2.txt": "legal on linux",
|
|
86
|
+
"/ok.txt": "fine"
|
|
87
|
+
},
|
|
88
|
+
steps: [runCycle(), runCycle()]
|
|
89
|
+
})
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(result.finalLocal["/report:v2.txt"]).toMatchObject({ type: "file" })
|
|
93
|
+
expect(result.finalLocal["/ok.txt"]).toMatchObject({ type: "file" })
|
|
94
|
+
expect(ignoredReasons(result.messages)).not.toContain("invalidPath")
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("AC3: darwin also skips the colon file — `:` is illegal on macOS too (unlike linux)", async () => {
|
|
98
|
+
const result = await withPlatform("darwin", () =>
|
|
99
|
+
runScenario({
|
|
100
|
+
name: "AC3",
|
|
101
|
+
mode: "twoWay",
|
|
102
|
+
initialRemote: {
|
|
103
|
+
"/a:b.txt": "illegal on macOS",
|
|
104
|
+
"/ok.txt": "fine"
|
|
105
|
+
},
|
|
106
|
+
steps: [runCycle(), runCycle()]
|
|
107
|
+
})
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
expect(result.finalLocal["/a:b.txt"]).toBeUndefined()
|
|
111
|
+
expect(result.finalLocal["/ok.txt"]).toMatchObject({ type: "file" })
|
|
112
|
+
expect(result.finalRemote["/a:b.txt"]).toMatchObject({ type: "file" })
|
|
113
|
+
expect(ignoredReasons(result.messages)).toContain("invalidPath")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("AC4: win32 skips remote files using reserved device names (CON, com1.txt, nul); a normal file syncs", async () => {
|
|
117
|
+
const result = await withPlatform("win32", () =>
|
|
118
|
+
runScenario({
|
|
119
|
+
name: "AC4",
|
|
120
|
+
mode: "cloudToLocal",
|
|
121
|
+
initialRemote: {
|
|
122
|
+
"/CON": "reserved",
|
|
123
|
+
"/com1.txt": "reserved even with an extension",
|
|
124
|
+
"/nul": "reserved",
|
|
125
|
+
"/normal.txt": "fine"
|
|
126
|
+
},
|
|
127
|
+
steps: [runCycle(), runCycle()]
|
|
128
|
+
})
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
expect(result.finalLocal["/CON"]).toBeUndefined()
|
|
132
|
+
expect(result.finalLocal["/com1.txt"]).toBeUndefined()
|
|
133
|
+
expect(result.finalLocal["/nul"]).toBeUndefined()
|
|
134
|
+
expect(result.finalLocal["/normal.txt"]).toMatchObject({ type: "file" })
|
|
135
|
+
expect(ignoredReasons(result.messages)).toContain("invalidPath")
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it("AC5: linux treats reserved device names as ordinary filenames and syncs them", async () => {
|
|
139
|
+
const result = await withPlatform("linux", () =>
|
|
140
|
+
runScenario({
|
|
141
|
+
name: "AC5",
|
|
142
|
+
mode: "cloudToLocal",
|
|
143
|
+
initialRemote: {
|
|
144
|
+
"/CON": "just a name on linux",
|
|
145
|
+
"/com1.txt": "fine",
|
|
146
|
+
"/nul": "fine"
|
|
147
|
+
},
|
|
148
|
+
steps: [runCycle(), runCycle()]
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
expect(result.finalLocal["/CON"]).toMatchObject({ type: "file" })
|
|
153
|
+
expect(result.finalLocal["/com1.txt"]).toMatchObject({ type: "file" })
|
|
154
|
+
expect(result.finalLocal["/nul"]).toMatchObject({ type: "file" })
|
|
155
|
+
expect(ignoredReasons(result.messages)).not.toContain("invalidPath")
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it("AC6: win32 skips each illegal metacharacter (< > | ? * \") while a clean file syncs", async () => {
|
|
159
|
+
const illegal: Record<string, string> = {
|
|
160
|
+
"/lt<name.txt": "x",
|
|
161
|
+
"/gt>name.txt": "x",
|
|
162
|
+
"/pipe|name.txt": "x",
|
|
163
|
+
"/q?name.txt": "x",
|
|
164
|
+
"/star*name.txt": "x",
|
|
165
|
+
"/quote\"name.txt": "x"
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const result = await withPlatform("win32", () =>
|
|
169
|
+
runScenario({
|
|
170
|
+
name: "AC6",
|
|
171
|
+
mode: "twoWay",
|
|
172
|
+
initialRemote: { ...illegal, "/clean.txt": "ok" },
|
|
173
|
+
steps: [runCycle(), runCycle()]
|
|
174
|
+
})
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
for (const path of Object.keys(illegal)) {
|
|
178
|
+
// Skipped locally (can't be represented on win32)…
|
|
179
|
+
expect(result.finalLocal[path]).toBeUndefined()
|
|
180
|
+
// …but never deleted from the remote.
|
|
181
|
+
expect(result.finalRemote[path]).toMatchObject({ type: "file" })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(result.finalLocal["/clean.txt"]).toMatchObject({ type: "file" })
|
|
185
|
+
expect(ignoredReasons(result.messages)).toContain("invalidPath")
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("AC7: win32 skips uploading a LOCAL file whose name is illegal on the platform (outbound guard)", async () => {
|
|
189
|
+
const result = await withPlatform("win32", () =>
|
|
190
|
+
runScenario({
|
|
191
|
+
name: "AC7",
|
|
192
|
+
mode: "twoWay",
|
|
193
|
+
initialLocal: {
|
|
194
|
+
"/local/bad:name.txt": "colon illegal on win32",
|
|
195
|
+
"/local/good.txt": "fine"
|
|
196
|
+
},
|
|
197
|
+
steps: [runCycle(), runCycle()]
|
|
198
|
+
})
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
// The illegal-named local file is never uploaded…
|
|
202
|
+
expect(result.finalRemote["/bad:name.txt"]).toBeUndefined()
|
|
203
|
+
// …but it stays on local disk (ignore ≠ delete) and the valid file uploads.
|
|
204
|
+
expect(result.finalLocal["/bad:name.txt"]).toMatchObject({ type: "file" })
|
|
205
|
+
expect(result.finalRemote["/good.txt"]).toMatchObject({ type: "file" })
|
|
206
|
+
expect(ignoredReasons(result.messages)).toContain("invalidPath")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("AC8: win32 skips a remote path over its 512-char limit (pathLength); a short sibling syncs", async () => {
|
|
210
|
+
const result = await withPlatform("win32", () =>
|
|
211
|
+
runScenario({
|
|
212
|
+
name: "AC8",
|
|
213
|
+
mode: "cloudToLocal",
|
|
214
|
+
initialRemote: {
|
|
215
|
+
[LONG_PATH]: "too long for windows",
|
|
216
|
+
"/short.txt": "fine"
|
|
217
|
+
},
|
|
218
|
+
steps: [runCycle(), runCycle()]
|
|
219
|
+
})
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(result.finalLocal[LONG_PATH]).toBeUndefined()
|
|
223
|
+
expect(result.finalLocal["/short.txt"]).toMatchObject({ type: "file" })
|
|
224
|
+
expect(ignoredReasons(result.messages)).toContain("pathLength")
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it("AC9: linux syncs the same long path — its 4096-char limit permits it", async () => {
|
|
228
|
+
const result = await withPlatform("linux", () =>
|
|
229
|
+
runScenario({
|
|
230
|
+
name: "AC9",
|
|
231
|
+
mode: "cloudToLocal",
|
|
232
|
+
initialRemote: {
|
|
233
|
+
[LONG_PATH]: "fine on linux",
|
|
234
|
+
"/short.txt": "fine"
|
|
235
|
+
},
|
|
236
|
+
steps: [runCycle(), runCycle()]
|
|
237
|
+
})
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
expect(result.finalLocal[LONG_PATH]).toMatchObject({ type: "file" })
|
|
241
|
+
expect(result.finalLocal["/short.txt"]).toMatchObject({ type: "file" })
|
|
242
|
+
expect(ignoredReasons(result.messages)).not.toContain("pathLength")
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("AC10: win32 converges a mixed tree (valid synced, illegal skipped) and stays stable across a restart", async () => {
|
|
246
|
+
const result = await withPlatform("win32", () =>
|
|
247
|
+
runScenario({
|
|
248
|
+
name: "AC10",
|
|
249
|
+
mode: "twoWay",
|
|
250
|
+
initialRemote: {
|
|
251
|
+
"/docs/readme.txt": "valid",
|
|
252
|
+
"/docs/a:b.txt": "illegal colon",
|
|
253
|
+
"/CON": "reserved",
|
|
254
|
+
"/photos/pic.txt": "valid"
|
|
255
|
+
},
|
|
256
|
+
steps: [runCycle(), runCycle(), restart(), runCycle()]
|
|
257
|
+
})
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
// Valid files synced down…
|
|
261
|
+
expect(result.finalLocal["/docs/readme.txt"]).toMatchObject({ type: "file" })
|
|
262
|
+
expect(result.finalLocal["/photos/pic.txt"]).toMatchObject({ type: "file" })
|
|
263
|
+
// …illegal ones skipped locally but intact on the remote.
|
|
264
|
+
expect(result.finalLocal["/docs/a:b.txt"]).toBeUndefined()
|
|
265
|
+
expect(result.finalLocal["/CON"]).toBeUndefined()
|
|
266
|
+
expect(result.finalRemote["/docs/a:b.txt"]).toMatchObject({ type: "file" })
|
|
267
|
+
expect(result.finalRemote["/CON"]).toMatchObject({ type: "file" })
|
|
268
|
+
|
|
269
|
+
// The post-restart cycle does NO transfers — the skipped files never cause repeated churn, and
|
|
270
|
+
// the valid files are already reconciled, so a long-lived win32 sync stays quiet.
|
|
271
|
+
const lastCycle = result.cycles[result.cycles.length - 1]!
|
|
272
|
+
|
|
273
|
+
expect(hadTransfers(lastCycle.messages)).toBe(false)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
// A name that fits in 255 UTF-16 code units but exceeds 255 UTF-8 BYTES — legal on macOS/Windows
|
|
277
|
+
// (which count code units) but over Linux's byte-based NAME_MAX. "あ" is 1 code unit / 3 bytes, so
|
|
278
|
+
// 100 of them is 100 units (under 255) yet 300 bytes (over 255).
|
|
279
|
+
const MULTIBYTE_NAME = "あ".repeat(100)
|
|
280
|
+
const MULTIBYTE_PATH = `/${MULTIBYTE_NAME}.txt`
|
|
281
|
+
|
|
282
|
+
it("AC11: linux skips a remote name that exceeds 255 BYTES (nameLength) instead of failing forever", async () => {
|
|
283
|
+
const result = await withPlatform("linux", () =>
|
|
284
|
+
runScenario({
|
|
285
|
+
name: "AC11",
|
|
286
|
+
mode: "cloudToLocal",
|
|
287
|
+
initialRemote: {
|
|
288
|
+
[MULTIBYTE_PATH]: "too many bytes for linux",
|
|
289
|
+
"/short.txt": "fine"
|
|
290
|
+
},
|
|
291
|
+
steps: [runCycle(), runCycle()]
|
|
292
|
+
})
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
// The over-long name is skipped (not materialized locally), the valid sibling syncs, and the remote
|
|
296
|
+
// copy is untouched — a graceful skip, not a per-cycle ENAMETOOLONG retry loop.
|
|
297
|
+
expect(result.finalLocal[MULTIBYTE_PATH]).toBeUndefined()
|
|
298
|
+
expect(result.finalLocal["/short.txt"]).toMatchObject({ type: "file" })
|
|
299
|
+
expect(result.finalRemote[MULTIBYTE_PATH]).toMatchObject({ type: "file" })
|
|
300
|
+
expect(ignoredReasons(result.messages)).toContain("nameLength")
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("AC12: darwin SYNCS the same name down — it is 100 UTF-16 units, under the 255-unit macOS limit", async () => {
|
|
304
|
+
const result = await withPlatform("darwin", () =>
|
|
305
|
+
runScenario({
|
|
306
|
+
name: "AC12",
|
|
307
|
+
mode: "cloudToLocal",
|
|
308
|
+
initialRemote: {
|
|
309
|
+
[MULTIBYTE_PATH]: "fine on macOS",
|
|
310
|
+
"/short.txt": "fine"
|
|
311
|
+
},
|
|
312
|
+
steps: [runCycle(), runCycle()]
|
|
313
|
+
})
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
expect(result.finalLocal[MULTIBYTE_PATH]).toMatchObject({ type: "file" })
|
|
317
|
+
expect(result.finalLocal["/short.txt"]).toMatchObject({ type: "file" })
|
|
318
|
+
expect(ignoredReasons(result.messages)).not.toContain("nameLength")
|
|
319
|
+
})
|
|
320
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest"
|
|
2
|
+
import { runScenario, runCycle } from "../harness/runner"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Category AD — Unicode normalization (KNOWN, DEFERRED LIMITATION).
|
|
6
|
+
*
|
|
7
|
+
* A filename like "cafe<accent>.txt" can be encoded two ways that look identical but are different bytes:
|
|
8
|
+
* - NFC (precomposed): the accented letter is one code point (U+00E9)
|
|
9
|
+
* - NFD (decomposed): base letter + combining accent (U+0065 U+0301)
|
|
10
|
+
*
|
|
11
|
+
* The engine compares names BYTEWISE and does NOT Unicode-normalize — and neither does @filen/sdk
|
|
12
|
+
* (verified: the SDK only uses path.normalize for `..`/separators, never String.normalize("NFC")). So
|
|
13
|
+
* the SAME visual filename in two normalization forms is treated as two distinct files, which on a
|
|
14
|
+
* mixed-normalization cross-sync ends as a permanent DUPLICATE on both sides.
|
|
15
|
+
*
|
|
16
|
+
* In practice NFD names come almost exclusively from old macOS HFS+ (which forced NFD on write); modern
|
|
17
|
+
* Windows/Linux/APFS-macOS produce NFC. For a modern user base this is rare, so the fix (NFC-normalize
|
|
18
|
+
* keys for comparison + migrate the persisted base trees so the first post-upgrade cycle stays a no-op)
|
|
19
|
+
* is DEFERRED by maintainer decision (2026-06-27). See docs/hardening-findings.md and the
|
|
20
|
+
* filen-sync-caveats memory.
|
|
21
|
+
*
|
|
22
|
+
* These tests PIN the current behavior (golden master): if normalization is ever added, AD1 flips and
|
|
23
|
+
* forces this note + the fix to be reconciled, rather than the change sliding in silently.
|
|
24
|
+
*
|
|
25
|
+
* NFC below is the precomposed byte sequence and NFD the decomposed one of the same visual name;
|
|
26
|
+
* verified distinct-but-NFC-equal in the first two assertions of AD1.
|
|
27
|
+
*/
|
|
28
|
+
const NFC: string = "café.txt" // precomposed: U+00E9
|
|
29
|
+
const NFD: string = "café.txt" // decomposed: U+0065 U+0301
|
|
30
|
+
|
|
31
|
+
describe("Category AD — Unicode normalization (known, deferred limitation)", () => {
|
|
32
|
+
it("AD1: KNOWN LIMITATION — NFC-remote vs NFD-local of the same visual name DUPLICATE (no normalization yet)", async () => {
|
|
33
|
+
// Sanity: different bytes, yet the SAME name once normalized — exactly the trap the engine misses.
|
|
34
|
+
expect(NFC).not.toBe(NFD)
|
|
35
|
+
expect(NFC.normalize("NFC")).toBe(NFD.normalize("NFC"))
|
|
36
|
+
|
|
37
|
+
const result = await runScenario({
|
|
38
|
+
name: "AD1",
|
|
39
|
+
mode: "twoWay",
|
|
40
|
+
initialLocal: { [`/local/${NFD}`]: "from-an-nfd-client" },
|
|
41
|
+
initialRemote: { [`/${NFC}`]: "from-an-nfc-client" },
|
|
42
|
+
steps: [runCycle(), runCycle(), runCycle()]
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const localForms = [NFC, NFD].filter(name => result.finalLocal[`/${name}`] !== undefined)
|
|
46
|
+
const remoteForms = [NFC, NFD].filter(name => result.finalRemote[`/${name}`] !== undefined)
|
|
47
|
+
|
|
48
|
+
// DOCUMENTED current behavior: BOTH forms end up on BOTH sides — the duplication bug. When
|
|
49
|
+
// NFC-normalized comparison lands, each side collapses to a single form and these flip to 1.
|
|
50
|
+
expect(localForms.length).toBe(2)
|
|
51
|
+
expect(remoteForms.length).toBe(2)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("AD2: a pure-ASCII name is unaffected (normalization would be a no-op here) — sanity", async () => {
|
|
55
|
+
const result = await runScenario({
|
|
56
|
+
name: "AD2",
|
|
57
|
+
mode: "twoWay",
|
|
58
|
+
initialLocal: { "/local/plain.txt": "ascii" },
|
|
59
|
+
initialRemote: { "/plain.txt": "ascii" },
|
|
60
|
+
steps: [runCycle(), runCycle()]
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// No duplication for ASCII: exactly one converged copy on each side.
|
|
64
|
+
expect(Object.keys(result.finalLocal).filter(key => key.includes("plain")).length).toBe(1)
|
|
65
|
+
expect(Object.keys(result.finalRemote).filter(key => key.includes("plain")).length).toBe(1)
|
|
66
|
+
})
|
|
67
|
+
})
|