@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.
Files changed (152) hide show
  1. package/.node-version +1 -1
  2. package/dist/ignorer.d.ts +6 -0
  3. package/dist/ignorer.js +43 -24
  4. package/dist/ignorer.js.map +1 -1
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.js +3 -1
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deltas.d.ts +58 -2
  9. package/dist/lib/deltas.js +693 -108
  10. package/dist/lib/deltas.js.map +1 -1
  11. package/dist/lib/environment.d.ts +47 -0
  12. package/dist/lib/environment.js +71 -0
  13. package/dist/lib/environment.js.map +1 -0
  14. package/dist/lib/filesystems/dirTree.d.ts +70 -0
  15. package/dist/lib/filesystems/dirTree.js +157 -0
  16. package/dist/lib/filesystems/dirTree.js.map +1 -0
  17. package/dist/lib/filesystems/local.d.ts +18 -8
  18. package/dist/lib/filesystems/local.js +166 -160
  19. package/dist/lib/filesystems/local.js.map +1 -1
  20. package/dist/lib/filesystems/remote.d.ts +12 -5
  21. package/dist/lib/filesystems/remote.js +226 -172
  22. package/dist/lib/filesystems/remote.js.map +1 -1
  23. package/dist/lib/ipc.js +1 -2
  24. package/dist/lib/ipc.js.map +1 -1
  25. package/dist/lib/lock.js +19 -12
  26. package/dist/lib/lock.js.map +1 -1
  27. package/dist/lib/logger.js +9 -7
  28. package/dist/lib/logger.js.map +1 -1
  29. package/dist/lib/state.js +159 -63
  30. package/dist/lib/state.js.map +1 -1
  31. package/dist/lib/sync.d.ts +18 -0
  32. package/dist/lib/sync.js +165 -96
  33. package/dist/lib/sync.js.map +1 -1
  34. package/dist/lib/tasks.d.ts +7 -8
  35. package/dist/lib/tasks.js +38 -45
  36. package/dist/lib/tasks.js.map +1 -1
  37. package/dist/semaphore.d.ts +1 -0
  38. package/dist/semaphore.js +22 -5
  39. package/dist/semaphore.js.map +1 -1
  40. package/dist/utils.js +51 -35
  41. package/dist/utils.js.map +1 -1
  42. package/eslint.config.mjs +36 -0
  43. package/package.json +19 -15
  44. package/tests/bench/collapse.bench.ts +114 -0
  45. package/tests/bench/cycle.bench.ts +111 -0
  46. package/tests/bench/deltas.bench.ts +151 -0
  47. package/tests/bench/harness/fake-sync.ts +32 -0
  48. package/tests/bench/harness/measure.ts +276 -0
  49. package/tests/bench/harness/scale-world.ts +160 -0
  50. package/tests/bench/harness/trees.ts +275 -0
  51. package/tests/bench/local-scan.bench.ts +74 -0
  52. package/tests/bench/longrun.bench.ts +130 -0
  53. package/tests/bench/profile-incremental.ts +90 -0
  54. package/tests/bench/remote-build.bench.ts +104 -0
  55. package/tests/bench/render.ts +14 -0
  56. package/tests/bench/semaphore.bench.ts +79 -0
  57. package/tests/bench/state.bench.ts +85 -0
  58. package/tests/bench/tasks-dispatch.bench.ts +156 -0
  59. package/tests/conformance/virtual-fs.test.ts +213 -0
  60. package/tests/e2e/backup.e2e.test.ts +130 -0
  61. package/tests/e2e/confirm.e2e.test.ts +191 -0
  62. package/tests/e2e/conflict.e2e.test.ts +261 -0
  63. package/tests/e2e/edge.e2e.test.ts +339 -0
  64. package/tests/e2e/harness/account.ts +104 -0
  65. package/tests/e2e/harness/assert.ts +127 -0
  66. package/tests/e2e/harness/drive.ts +88 -0
  67. package/tests/e2e/harness/mutations.ts +249 -0
  68. package/tests/e2e/harness/world.ts +222 -0
  69. package/tests/e2e/ignore.e2e.test.ts +123 -0
  70. package/tests/e2e/lifecycle.e2e.test.ts +290 -0
  71. package/tests/e2e/modes.e2e.test.ts +215 -0
  72. package/tests/e2e/platform.e2e.test.ts +157 -0
  73. package/tests/e2e/property.e2e.test.ts +163 -0
  74. package/tests/e2e/races.e2e.test.ts +90 -0
  75. package/tests/e2e/regressions.e2e.test.ts +212 -0
  76. package/tests/e2e/resilience.e2e.test.ts +231 -0
  77. package/tests/e2e/special.e2e.test.ts +185 -0
  78. package/tests/e2e/state.e2e.test.ts +229 -0
  79. package/tests/e2e/sync.e2e.test.ts +222 -0
  80. package/tests/fakes/fake-cloud.test.ts +267 -0
  81. package/tests/fakes/fake-cloud.ts +1094 -0
  82. package/tests/fakes/virtual-fs.ts +354 -0
  83. package/tests/harness/known-bug.ts +17 -0
  84. package/tests/harness/mutations.ts +65 -0
  85. package/tests/harness/runner.ts +141 -0
  86. package/tests/harness/snapshot.ts +113 -0
  87. package/tests/harness/world.ts +187 -0
  88. package/tests/scenarios/a-baseline.test.ts +107 -0
  89. package/tests/scenarios/aa-races.test.ts +258 -0
  90. package/tests/scenarios/ab-mode-property.test.ts +189 -0
  91. package/tests/scenarios/ac-platform.test.ts +320 -0
  92. package/tests/scenarios/ad-unicode-normalization.test.ts +67 -0
  93. package/tests/scenarios/b-additions.test.ts +160 -0
  94. package/tests/scenarios/c-modifications.test.ts +194 -0
  95. package/tests/scenarios/d-deletions.test.ts +259 -0
  96. package/tests/scenarios/e-rename-move.test.ts +288 -0
  97. package/tests/scenarios/f-ignore-filter.test.ts +346 -0
  98. package/tests/scenarios/g-large-deletion.test.ts +277 -0
  99. package/tests/scenarios/h-resilience.test.ts +167 -0
  100. package/tests/scenarios/i-lifecycle.test.ts +353 -0
  101. package/tests/scenarios/j-state-cache.test.ts +264 -0
  102. package/tests/scenarios/k-scale.test.ts +202 -0
  103. package/tests/scenarios/l-property.test.ts +145 -0
  104. package/tests/scenarios/m-golden.test.ts +452 -0
  105. package/tests/scenarios/o-task-errors.test.ts +497 -0
  106. package/tests/scenarios/p-remote-originated.test.ts +306 -0
  107. package/tests/scenarios/q-cycle-lifecycle.test.ts +234 -0
  108. package/tests/scenarios/r-rename-stress.test.ts +208 -0
  109. package/tests/scenarios/s-upgrade-transition.test.ts +171 -0
  110. package/tests/scenarios/t-type-change.test.ts +144 -0
  111. package/tests/scenarios/u-mode-local-to-cloud.test.ts +347 -0
  112. package/tests/scenarios/v-mode-local-backup.test.ts +201 -0
  113. package/tests/scenarios/w-mode-cloud-to-local.test.ts +304 -0
  114. package/tests/scenarios/x-mode-cloud-backup.test.ts +201 -0
  115. package/tests/scenarios/y-conflict-matrix.test.ts +292 -0
  116. package/tests/scenarios/z-cross-ops.test.ts +285 -0
  117. package/tests/scenarios/zb-dir-rename-cross.test.ts +296 -0
  118. package/tests/scenarios/zc-crash-recovery.test.ts +189 -0
  119. package/tests/scenarios/zd-inode-reuse.test.ts +118 -0
  120. package/tests/scenarios/ze-move-into-new-dir.test.ts +130 -0
  121. package/tests/scenarios/zf-remote-change-unchanged-local.test.ts +81 -0
  122. package/tests/scenarios/zg-edit-during-scan.test.ts +68 -0
  123. package/tests/scenarios/zh-dir-delete-vs-child.test.ts +104 -0
  124. package/tests/scenarios/zi-smoke-test-outage.test.ts +78 -0
  125. package/tests/scenarios/zj-trash-cleanup.test.ts +133 -0
  126. package/tests/scenarios/zk-ignore-asymmetry.test.ts +150 -0
  127. package/tests/scenarios/zl-mode-atomicity.test.ts +104 -0
  128. package/tests/scenarios/zm-scan-concurrency.test.ts +78 -0
  129. package/tests/scenarios/zn-delta-ordering.test.ts +130 -0
  130. package/tests/scenarios/zo-download-temp-cleanup.test.ts +65 -0
  131. package/tests/unit/collapse-deltas.test.ts +276 -0
  132. package/tests/unit/dir-tree.test.ts +159 -0
  133. package/tests/unit/icloud.test.ts +115 -0
  134. package/tests/unit/ignorer-cache-regression.test.ts +70 -0
  135. package/tests/unit/ignorer.test.ts +63 -0
  136. package/tests/unit/ipc-lock.test.ts +438 -0
  137. package/tests/unit/lock.test.ts +135 -0
  138. package/tests/unit/n-unit.test.ts +632 -0
  139. package/tests/unit/remote-tree-unordered-regression.test.ts +101 -0
  140. package/tests/unit/semaphore-regression.test.ts +140 -0
  141. package/tests/unit/state-refencode-regression.test.ts +224 -0
  142. package/tests/unit/state.test.ts +809 -0
  143. package/tests/unit/tasks-dispatch-order-regression.test.ts +53 -0
  144. package/tests/unit/worker-api.test.ts +379 -0
  145. package/tsconfig.json +10 -1
  146. package/tsconfig.test.json +12 -0
  147. package/tsconfig.tsbuildinfo +1 -0
  148. package/vitest.bench.config.ts +32 -0
  149. package/vitest.config.ts +27 -0
  150. package/vitest.e2e.config.ts +68 -0
  151. package/.eslintrc +0 -16
  152. 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
+ })