@gentleduck/upload 0.2.5 → 0.3.0

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 (129) hide show
  1. package/README.md +297 -0
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -90
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -0,0 +1,191 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import type { Engine } from '../core/engine/engine.types'
3
+ import { createMemoryAdapter, MemoryAdapter } from '../core/persistence'
4
+ import { deserializeSnapshot, serializeSnapshot } from '../core/persistence/persistence'
5
+ import type { UploadPersistence } from '../core/persistence/persistence.types'
6
+
7
+ type Intents = { post: { strategy: 'post'; fileId: string; url: string } }
8
+ type Cursors = { post: { offset: number } }
9
+ type Purpose = 'avatar'
10
+ type Result = { fileId: string; key: string }
11
+
12
+ const isPurpose = (v: string): v is Purpose => v === 'avatar'
13
+ const isIntent = (v: unknown): v is Intents[keyof Intents] => {
14
+ return (
15
+ typeof v === 'object' &&
16
+ v !== null &&
17
+ (v as { strategy?: unknown }).strategy === 'post' &&
18
+ typeof (v as { fileId?: unknown }).fileId === 'string'
19
+ )
20
+ }
21
+ const hasStrategy = (s: string) => s === 'post'
22
+
23
+ describe('serializeSnapshot', () => {
24
+ test('skips items without intents and terminal states', () => {
25
+ const state: Engine.State<Intents, Cursors, Purpose, Result> = {
26
+ items: new Map([
27
+ [
28
+ 'a',
29
+ {
30
+ phase: 'validating',
31
+ localId: 'a',
32
+ purpose: 'avatar',
33
+ fingerprint: { name: 'a', size: 1, type: 't', lastModified: 0 },
34
+ file: new File(['a'], 'a'),
35
+ createdAt: 0,
36
+ steps: [],
37
+ meta: {},
38
+ },
39
+ ],
40
+ [
41
+ 'b',
42
+ {
43
+ phase: 'completed',
44
+ localId: 'b',
45
+ purpose: 'avatar',
46
+ fingerprint: { name: 'b', size: 1, type: 't', lastModified: 0 },
47
+ file: new File(['b'], 'b'),
48
+ intent: { strategy: 'post', fileId: 'b', url: 'x' },
49
+ completedBy: 'upload',
50
+ result: { fileId: 'b', key: 'k/b' },
51
+ completedAt: 0,
52
+ createdAt: 0,
53
+ steps: [],
54
+ meta: {},
55
+ },
56
+ ],
57
+ [
58
+ 'c',
59
+ {
60
+ phase: 'uploading',
61
+ localId: 'c',
62
+ purpose: 'avatar',
63
+ fingerprint: { name: 'c', size: 10, type: 't', lastModified: 0 },
64
+ file: new File(['c'], 'c'),
65
+ intent: { strategy: 'post', fileId: 'c', url: 'x' },
66
+ startedAt: 0,
67
+ progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
68
+ createdAt: 0,
69
+ cursor: { strategy: 'post', value: { offset: 5 } },
70
+ steps: [],
71
+ meta: {},
72
+ },
73
+ ],
74
+ ]),
75
+ }
76
+
77
+ const snap = serializeSnapshot(state, 1)
78
+ expect(Object.keys(snap.items)).toEqual(['c'])
79
+ expect(snap.version).toBe(1)
80
+ })
81
+ })
82
+
83
+ describe('deserializeSnapshot', () => {
84
+ test('restores resumable items into the paused phase', () => {
85
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = {
86
+ version: 1,
87
+ createdAt: 100,
88
+ items: {
89
+ c: {
90
+ id: 'c',
91
+ purpose: 'avatar',
92
+ status: 'uploading',
93
+ file: { name: 'c', size: 10, type: 't', lastModified: 0 },
94
+ intent: { strategy: 'post', fileId: 'c', url: 'x' },
95
+ cursor: { strategy: 'post', value: { offset: 5 } },
96
+ progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
97
+ },
98
+ },
99
+ }
100
+
101
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
102
+ isPurpose,
103
+ isIntent,
104
+ hasStrategy,
105
+ })
106
+ expect(out).not.toBeNull()
107
+ const item = out?.items.get('c')
108
+ expect(item?.phase).toBe('paused')
109
+ if (item?.phase === 'paused') {
110
+ expect(item.cursor).toEqual({ strategy: 'post', value: { offset: 5 } })
111
+ expect(item.progress.pct).toBe(50)
112
+ }
113
+ })
114
+
115
+ test('drops items missing a cursor', () => {
116
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = {
117
+ version: 1,
118
+ createdAt: 100,
119
+ items: {
120
+ c: {
121
+ id: 'c',
122
+ purpose: 'avatar',
123
+ status: 'uploading',
124
+ file: { name: 'c', size: 10, type: 't', lastModified: 0 },
125
+ intent: { strategy: 'post', fileId: 'c', url: 'x' },
126
+ },
127
+ },
128
+ }
129
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
130
+ isPurpose,
131
+ isIntent,
132
+ hasStrategy,
133
+ })
134
+ expect(out?.items.size).toBe(0)
135
+ })
136
+
137
+ test('returns null without guards', () => {
138
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
139
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, { hasStrategy })
140
+ expect(out).toBeNull()
141
+ })
142
+
143
+ test('accepts snapshots and ignores unrecognized version when not enforced', () => {
144
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
145
+ const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
146
+ isPurpose,
147
+ isIntent,
148
+ hasStrategy,
149
+ })
150
+ expect(out).not.toBeNull()
151
+ expect(out?.items.size).toBe(0)
152
+ })
153
+ })
154
+
155
+ describe('MemoryAdapter', () => {
156
+ test('round-trips a value', async () => {
157
+ await MemoryAdapter.save('k', { a: 1 })
158
+ expect(await MemoryAdapter.load('k')).toEqual({ a: 1 })
159
+ await MemoryAdapter.clear('k')
160
+ expect(await MemoryAdapter.load('k')).toBeNull()
161
+ })
162
+ })
163
+
164
+ describe('createMemoryAdapter factory isolation', () => {
165
+ test('two factory calls produce isolated state', async () => {
166
+ const a = createMemoryAdapter()
167
+ const b = createMemoryAdapter()
168
+ await a.save('key', { from: 'a' })
169
+ expect(await b.load('key')).toBeNull()
170
+ await b.save('key', { from: 'b' })
171
+ expect(await a.load('key')).toEqual({ from: 'a' })
172
+ expect(await b.load('key')).toEqual({ from: 'b' })
173
+ })
174
+
175
+ test('clear on one adapter does not affect another', async () => {
176
+ const a = createMemoryAdapter()
177
+ const b = createMemoryAdapter()
178
+ await a.save('shared', 1)
179
+ await b.save('shared', 2)
180
+ await a.clear('shared')
181
+ expect(await a.load('shared')).toBeNull()
182
+ expect(await b.load('shared')).toBe(2)
183
+ })
184
+
185
+ test('backwards-compat singleton still round-trips', async () => {
186
+ await MemoryAdapter.save('compat', { v: 1 })
187
+ expect(await MemoryAdapter.load('compat')).toEqual({ v: 1 })
188
+ await MemoryAdapter.clear('compat')
189
+ expect(await MemoryAdapter.load('compat')).toBeNull()
190
+ })
191
+ })
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { sanitizeFilename } from '../core/utils/sanitize-filename'
3
+
4
+ describe('sanitizeFilename', () => {
5
+ test('happy path: ordinary name passes through unchanged', () => {
6
+ const r = sanitizeFilename('report-2026.pdf')
7
+ expect(r).toEqual({ safe: true, normalised: 'report-2026.pdf' })
8
+ })
9
+
10
+ test('CON.txt rejected as reserved (Windows device name)', () => {
11
+ const r = sanitizeFilename('CON.txt')
12
+ expect(r.safe).toBe(false)
13
+ expect(r.safe === false && r.reason).toBe('reserved')
14
+ })
15
+
16
+ test('lowercase nul rejected as reserved (case-insensitive)', () => {
17
+ const r = sanitizeFilename('nul')
18
+ expect(r.safe).toBe(false)
19
+ expect(r.safe === false && r.reason).toBe('reserved')
20
+ })
21
+
22
+ test('COM1.tar.gz rejected (reserved base, ignoring extension)', () => {
23
+ const r = sanitizeFilename('COM1.tar.gz')
24
+ expect(r.safe).toBe(false)
25
+ expect(r.safe === false && r.reason).toBe('reserved')
26
+ })
27
+
28
+ test('-rm-rf.txt → leading dash stripped, becomes rm-rf.txt', () => {
29
+ const r = sanitizeFilename('-rm-rf.txt')
30
+ expect(r).toEqual({ safe: true, normalised: 'rm-rf.txt' })
31
+ })
32
+
33
+ test('multiple leading dashes all stripped', () => {
34
+ const r = sanitizeFilename('---flag.bin')
35
+ expect(r).toEqual({ safe: true, normalised: 'flag.bin' })
36
+ })
37
+
38
+ test('trailing dots and spaces stripped (Windows behaviour)', () => {
39
+ expect(sanitizeFilename('file.txt.').normalised).toBe('file.txt')
40
+ expect(sanitizeFilename('file.txt ').normalised).toBe('file.txt')
41
+ expect(sanitizeFilename('file.txt. . .').normalised).toBe('file.txt')
42
+ })
43
+
44
+ test('RTL override and other compat chars get NFKC normalised', () => {
45
+ // U+202E RIGHT-TO-LEFT OVERRIDE stripped via NFKC?
46
+ // NFKC does not strip RLO, but the test confirms normalize() is applied;
47
+ // we focus on the fullwidth → ASCII collapse which is the typical bypass.
48
+ const fullwidth = 'full.pdf' // "full.pdf" → "full.pdf"
49
+ const r = sanitizeFilename(fullwidth)
50
+ expect(r).toEqual({ safe: true, normalised: 'full.pdf' })
51
+ })
52
+
53
+ test('control chars cleaned (NUL byte)', () => {
54
+ const r = sanitizeFilename('file\x00name.txt')
55
+ expect(r).toEqual({ safe: true, normalised: 'filename.txt' })
56
+ })
57
+
58
+ test('control chars cleaned (CR/LF header smuggling)', () => {
59
+ const r = sanitizeFilename('a\r\nb.txt')
60
+ expect(r).toEqual({ safe: true, normalised: 'ab.txt' })
61
+ })
62
+
63
+ test('DEL (0x7F) is stripped', () => {
64
+ const r = sanitizeFilename('a\x7Fb.txt')
65
+ expect(r).toEqual({ safe: true, normalised: 'ab.txt' })
66
+ })
67
+
68
+ test('300-char name rejected as too-long', () => {
69
+ const r = sanitizeFilename('a'.repeat(300) + '.txt')
70
+ expect(r.safe).toBe(false)
71
+ expect(r.safe === false && r.reason).toBe('too-long')
72
+ })
73
+
74
+ test('exactly 255 chars passes', () => {
75
+ const name = 'a'.repeat(251) + '.txt' // 255 total
76
+ const r = sanitizeFilename(name)
77
+ expect(r.safe).toBe(true)
78
+ })
79
+
80
+ test('forward slash path separator rejected', () => {
81
+ const r = sanitizeFilename('foo/bar.txt')
82
+ expect(r.safe).toBe(false)
83
+ expect(r.safe === false && r.reason).toBe('path-sep')
84
+ })
85
+
86
+ test('backslash path separator rejected', () => {
87
+ const r = sanitizeFilename('foo\\bar.txt')
88
+ expect(r.safe).toBe(false)
89
+ expect(r.safe === false && r.reason).toBe('path-sep')
90
+ })
91
+
92
+ test('empty input rejected', () => {
93
+ const r = sanitizeFilename('')
94
+ expect(r.safe).toBe(false)
95
+ expect(r.safe === false && r.reason).toBe('empty')
96
+ })
97
+
98
+ test('name that vanishes after sanitisation rejected as empty', () => {
99
+ const r = sanitizeFilename('---. . .')
100
+ expect(r.safe).toBe(false)
101
+ expect(r.safe === false && r.reason).toBe('empty')
102
+ })
103
+
104
+ test('control-only input rejected as empty', () => {
105
+ const r = sanitizeFilename('\x00\x01\x02')
106
+ expect(r.safe).toBe(false)
107
+ expect(r.safe === false && r.reason).toBe('empty')
108
+ })
109
+ })
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { sleep } from '../core/utils/async'
3
+
4
+ describe('sleep - clamps adversarial inputs', () => {
5
+ test('NaN resolves immediately', async () => {
6
+ const t0 = Date.now()
7
+ await sleep(Number.NaN)
8
+ expect(Date.now() - t0).toBeLessThan(50)
9
+ })
10
+
11
+ test('Infinity resolves within the 24h cap (but we only wait briefly to confirm it scheduled)', async () => {
12
+ // Can't actually wait 24h; assert that the call doesn't throw / hang the test.
13
+ const p = sleep(Number.POSITIVE_INFINITY)
14
+ expect(p).toBeInstanceOf(Promise)
15
+ })
16
+
17
+ test('negative ms resolves immediately', async () => {
18
+ const t0 = Date.now()
19
+ await sleep(-1000)
20
+ expect(Date.now() - t0).toBeLessThan(50)
21
+ })
22
+
23
+ test('zero ms resolves immediately', async () => {
24
+ const t0 = Date.now()
25
+ await sleep(0)
26
+ expect(Date.now() - t0).toBeLessThan(50)
27
+ })
28
+
29
+ test('finite positive ms waits at least that long', async () => {
30
+ const t0 = Date.now()
31
+ await sleep(20)
32
+ expect(Date.now() - t0).toBeGreaterThanOrEqual(15)
33
+ })
34
+ })
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Static-analysis assertions that enforce security invariants by
3
+ * grepping the source tree at test time.
4
+ *
5
+ * These guard the class of bug that survives a refactor + the existing
6
+ * happy-path tests: someone replaces `crypto.randomUUID` with
7
+ * `Math.random()`, or removes the SSRF guard from a strategy, and the
8
+ * existing tests still pass because they don't cover the regression
9
+ * lane. If one of these tests fails, fix the source - do not allowlist
10
+ * without understanding which threat the rule was protecting against.
11
+ */
12
+
13
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
14
+ import { dirname, join } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+ import { describe, expect, it } from 'vitest'
17
+
18
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
19
+
20
+ interface FileScan {
21
+ path: string
22
+ contents: string
23
+ }
24
+
25
+ function walkTs(dir: string, out: FileScan[] = []): FileScan[] {
26
+ for (const entry of readdirSync(dir)) {
27
+ if (entry === 'node_modules' || entry === 'dist' || entry.startsWith('__test')) continue
28
+ const full = join(dir, entry)
29
+ const stat = statSync(full)
30
+ if (stat.isDirectory()) {
31
+ walkTs(full, out)
32
+ } else if (entry.endsWith('.ts') || entry.endsWith('.tsx')) {
33
+ out.push({ path: full.slice(ROOT.length + 1), contents: readFileSync(full, 'utf8') })
34
+ }
35
+ }
36
+ return out
37
+ }
38
+
39
+ const ALL_FILES = walkTs(ROOT)
40
+
41
+ const SECURITY_PATHS = ['core/utils/', 'core/engine/', 'core/persistence/', 'core/contracts/', 'strategies/']
42
+
43
+ function isSecurityPath(path: string): boolean {
44
+ return SECURITY_PATHS.some((p) => path.includes(p))
45
+ }
46
+
47
+ function linesContaining(file: FileScan, pattern: RegExp): string[] {
48
+ const hits: string[] = []
49
+ for (const line of file.contents.split('\n')) {
50
+ const trimmed = line.trim()
51
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue
52
+ if (pattern.test(line)) hits.push(line)
53
+ }
54
+ return hits
55
+ }
56
+
57
+ describe('No Math.random in security paths', () => {
58
+ it('Math.random must not appear in core/utils, core/engine, core/persistence, core/contracts, strategies', () => {
59
+ const offenders = ALL_FILES.filter((f) => isSecurityPath(f.path)).flatMap((f) =>
60
+ linesContaining(f, /\bMath\.random\b/).map((l) => `${f.path}: ${l.trim()}`),
61
+ )
62
+ expect(offenders).toEqual([])
63
+ })
64
+ })
65
+
66
+ describe('Upload IDs come from a CSPRNG, never Math.random', () => {
67
+ it('id.ts uses randomUUID + getRandomValues; no Math.random in code', () => {
68
+ const id = ALL_FILES.find((f) => f.path === 'core/utils/id.ts')
69
+ expect(id).toBeDefined()
70
+ if (!id) return
71
+ expect(id.contents).toMatch(/\brandomUUID\b/)
72
+ expect(id.contents).toMatch(/\bgetRandomValues\b/)
73
+ // Use linesContaining so the docstring mention ("Math.random is deliberately
74
+ // NOT used") doesn't flag - we only inspect code lines.
75
+ expect(linesContaining(id, /Math\.random/)).toEqual([])
76
+ })
77
+ })
78
+
79
+ describe('Every strategy with a backend-supplied URL routes it through validateUploadUrl', () => {
80
+ it('multipart strategy imports validateUploadUrl', () => {
81
+ const f = ALL_FILES.find((f) => f.path === 'strategies/multipart/index.ts')
82
+ expect(f).toBeDefined()
83
+ if (!f) return
84
+ expect(f.contents).toMatch(/validateUploadUrl/)
85
+ })
86
+ it('post strategy imports validateUploadUrl', () => {
87
+ const f = ALL_FILES.find((f) => f.path === 'strategies/post/index.ts')
88
+ expect(f).toBeDefined()
89
+ if (!f) return
90
+ expect(f.contents).toMatch(/validateUploadUrl/)
91
+ })
92
+ })
93
+
94
+ describe('Persistence deserializer guards against NaN / Infinity / negative byte counts', () => {
95
+ it('persistence.ts uses Number.isFinite on numeric fields', () => {
96
+ const f = ALL_FILES.find((f) => f.path === 'core/persistence/persistence.ts')
97
+ expect(f).toBeDefined()
98
+ if (!f) return
99
+ expect(f.contents).toMatch(/Number\.isFinite|isFiniteNumber/)
100
+ })
101
+ })
102
+
103
+ describe('Persistence adapters strip dangerous keys before deserialization', () => {
104
+ it('LocalStorageAdapter applies stripDangerousKeys to JSON.parse output', () => {
105
+ const f = ALL_FILES.find((f) => f.path === 'core/persistence/adapters.local.ts')
106
+ expect(f).toBeDefined()
107
+ if (!f) return
108
+ expect(f.contents).toMatch(/stripDangerousKeys\(JSON\.parse/)
109
+ })
110
+ it('IndexedDB adapter applies stripDangerousKeys to read result', () => {
111
+ const f = ALL_FILES.find((f) => f.path === 'core/persistence/adapters.indexeddb.ts')
112
+ expect(f).toBeDefined()
113
+ if (!f) return
114
+ expect(f.contents).toMatch(/stripDangerousKeys/)
115
+ })
116
+ })
117
+
118
+ describe('UploadEngineError uses static messages (SEC-003)', () => {
119
+ it('errors.ts does NOT interpolate tainted values into Error.message', () => {
120
+ const f = ALL_FILES.find((f) => f.path === 'core/errors.ts')
121
+ expect(f).toBeDefined()
122
+ if (!f) return
123
+ // The constructor must lookup STATIC_MESSAGES[code] - no template-string interpolation
124
+ // of caller-supplied context values into the super() call.
125
+ expect(f.contents).toMatch(/STATIC_MESSAGES\[code\]/)
126
+ })
127
+ })
128
+
129
+ describe('Filename sanitization is wired into addFiles', () => {
130
+ it('create-intent.ts calls sanitizeFilename', () => {
131
+ const f = ALL_FILES.find((f) => f.path === 'core/engine/store/handlers/create-intent.ts')
132
+ expect(f).toBeDefined()
133
+ if (!f) return
134
+ expect(f.contents).toMatch(/sanitizeFilename/)
135
+ })
136
+ })
137
+
138
+ describe('No console.log in security paths (use console.warn for visibility)', () => {
139
+ it('security paths never call console.log', () => {
140
+ const offenders = ALL_FILES.filter((f) => isSecurityPath(f.path)).flatMap((f) =>
141
+ linesContaining(f, /\bconsole\.log\b/).map((l) => `${f.path}: ${l.trim()}`),
142
+ )
143
+ expect(offenders).toEqual([])
144
+ })
145
+ })
@@ -0,0 +1,192 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import { resolveUploadConfig } from '../core/client'
3
+ import {
4
+ calculateFileChecksum,
5
+ isAbortError,
6
+ isMultipartIntent,
7
+ normalizeError,
8
+ retryDecision,
9
+ } from '../core/engine/store/store.libs'
10
+
11
+ describe('normalizeError', () => {
12
+ test('honors a custom normalizer', () => {
13
+ const err = normalizeError('anything', () => ({ code: 'auth', message: 'no', retryable: false }))
14
+ expect(err.code).toBe('auth')
15
+ })
16
+
17
+ test('classifies abort-shaped errors as aborted/non-retryable', () => {
18
+ const err = normalizeError({ code: 'aborted', reason: 'cancel' })
19
+ expect(err.code).toBe('aborted')
20
+ expect(err.retryable).toBe(false)
21
+ })
22
+
23
+ test('thrown value with statusCode alias classifies as http', () => {
24
+ const err = normalizeError({ statusCode: 503, message: 'down' })
25
+ expect(err.code).toBe('http')
26
+ expect((err as { status?: number }).status).toBe(503)
27
+ expect(err.retryable).toBe(true)
28
+ })
29
+
30
+ test('http 5xx is retryable', () => {
31
+ const err = normalizeError({ status: 503, message: 'down' })
32
+ expect(err.code).toBe('http')
33
+ expect(err.retryable).toBe(true)
34
+ })
35
+
36
+ test('http 429 is retryable', () => {
37
+ const err = normalizeError({ status: 429, message: 'slow down' })
38
+ expect(err.code).toBe('http')
39
+ expect(err.retryable).toBe(true)
40
+ })
41
+
42
+ test('http 400 is not retryable', () => {
43
+ const err = normalizeError({ status: 400, message: 'bad' })
44
+ expect(err.code).toBe('http')
45
+ expect(err.retryable).toBe(false)
46
+ })
47
+
48
+ test('falls back to unknown non-retryable', () => {
49
+ const err = normalizeError({})
50
+ expect(err.code).toBe('unknown')
51
+ expect(err.retryable).toBe(false)
52
+ })
53
+ })
54
+
55
+ describe('retryDecision', () => {
56
+ const config = resolveUploadConfig({ maxAttempts: 3 })
57
+
58
+ test('auth errors never retry', () => {
59
+ expect(
60
+ retryDecision(config, { phase: 'intent', attempt: 1, error: { code: 'auth', message: '' } as never }),
61
+ ).toEqual({
62
+ retryable: false,
63
+ })
64
+ })
65
+
66
+ test('validation_failed never retries', () => {
67
+ expect(
68
+ retryDecision(config, {
69
+ phase: 'intent',
70
+ attempt: 1,
71
+ error: { code: 'validation_failed', message: '', reason: { code: 'empty_file' } } as never,
72
+ }),
73
+ ).toEqual({ retryable: false })
74
+ })
75
+
76
+ test('aborted never retries', () => {
77
+ expect(
78
+ retryDecision(config, { phase: 'upload', attempt: 1, error: { code: 'aborted', message: '' } as never }),
79
+ ).toEqual({ retryable: false })
80
+ })
81
+
82
+ test('stops retrying once attempt >= maxAttempts', () => {
83
+ expect(
84
+ retryDecision(config, { phase: 'upload', attempt: 3, error: { code: 'network', message: '' } as never }),
85
+ ).toEqual({ retryable: false })
86
+ })
87
+
88
+ test('exponential backoff escalates by attempt', () => {
89
+ const a1 = retryDecision(config, { phase: 'upload', attempt: 1, error: { code: 'network', message: '' } as never })
90
+ const a2 = retryDecision(config, { phase: 'upload', attempt: 2, error: { code: 'network', message: '' } as never })
91
+ expect(a1.retryable).toBe(true)
92
+ expect(a2.retryable).toBe(true)
93
+ if (a1.retryable && a2.retryable) {
94
+ expect(a2.delayMs).toBeGreaterThan(a1.delayMs ?? 0)
95
+ }
96
+ })
97
+ })
98
+
99
+ describe('isAbortError', () => {
100
+ test('matches the aborted shape', () => {
101
+ expect(isAbortError({ code: 'aborted' })).toBe(true)
102
+ expect(isAbortError({ code: 'aborted', reason: 'pause' })).toBe(true)
103
+ })
104
+
105
+ test('rejects other shapes', () => {
106
+ expect(isAbortError(null)).toBe(false)
107
+ expect(isAbortError({})).toBe(false)
108
+ expect(isAbortError({ code: 'http' })).toBe(false)
109
+ })
110
+ })
111
+
112
+ describe('isMultipartIntent', () => {
113
+ test('accepts a complete multipart intent', () => {
114
+ expect(isMultipartIntent({ strategy: 'multipart', fileId: 'f', uploadId: 'u', partSize: 1 })).toBe(true)
115
+ })
116
+
117
+ test('rejects when fields are missing', () => {
118
+ expect(isMultipartIntent({ strategy: 'multipart' })).toBe(false)
119
+ expect(isMultipartIntent({ strategy: 'post', fileId: 'f', uploadId: 'u' })).toBe(false)
120
+ })
121
+ })
122
+
123
+ describe('calculateFileChecksum', () => {
124
+ /** Reference digest via native Web Crypto for cross-checking the incremental path. */
125
+ async function subtleHex(bytes: Uint8Array): Promise<string> {
126
+ const digest = await crypto.subtle.digest('SHA-256', bytes as unknown as BufferSource)
127
+ return Array.from(new Uint8Array(digest))
128
+ .map((b) => b.toString(16).padStart(2, '0'))
129
+ .join('')
130
+ }
131
+
132
+ test('produces a hex digest', async () => {
133
+ const file = new File([new Uint8Array([0, 1, 2, 3])], 'a.bin')
134
+ const h = await calculateFileChecksum(file)
135
+ expect(h).toMatch(/^[0-9a-f]{64}$/)
136
+ })
137
+
138
+ test('different files produce different hashes', async () => {
139
+ const a = new File([new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 9])], 'a.bin')
140
+ const b = new File([new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 1])], 'b.bin')
141
+ const ha = await calculateFileChecksum(a)
142
+ const hb = await calculateFileChecksum(b)
143
+ expect(ha).not.toBe(hb)
144
+ })
145
+
146
+ test('file at the boundary uses the inline Web Crypto path (arrayBuffer once)', async () => {
147
+ const bytes = new Uint8Array(64)
148
+ for (let i = 0; i < bytes.length; i++) bytes[i] = i
149
+ const file = new File([bytes], 'cap.bin')
150
+ const abSpy = vi.spyOn(file, 'arrayBuffer')
151
+ const h = await calculateFileChecksum(file, 64) // size 64, boundary 64 -> inline
152
+ expect(h).toBe(await subtleHex(bytes))
153
+ expect(abSpy).toHaveBeenCalledTimes(1)
154
+ })
155
+
156
+ test('file below the boundary uses the inline Web Crypto path', async () => {
157
+ const bytes = new Uint8Array(32)
158
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 3) & 0xff
159
+ const file = new File([bytes], 'small.bin')
160
+ const abSpy = vi.spyOn(file, 'arrayBuffer')
161
+ const h = await calculateFileChecksum(file, 64) // size 32 < boundary 64
162
+ expect(h).toBe(await subtleHex(bytes))
163
+ expect(abSpy).toHaveBeenCalledTimes(1)
164
+ })
165
+
166
+ test('file above the boundary is hashed incrementally and matches Web Crypto', async () => {
167
+ const bytes = new Uint8Array(128)
168
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 7) & 0xff
169
+ const file = new File([bytes], 'big.bin')
170
+ // No Worker in the test realm -> inline-incremental over slices.
171
+ const h = await calculateFileChecksum(file, 16) // size 128 > boundary 16
172
+ expect(h).toBe(await subtleHex(bytes))
173
+ })
174
+
175
+ test('incremental path is stable across boundary/chunk splits', async () => {
176
+ const bytes = new Uint8Array(1000)
177
+ for (let i = 0; i < bytes.length; i++) bytes[i] = (i * 31 + 7) & 0xff
178
+ const file = new File([bytes], 'span.bin')
179
+ const expected = await subtleHex(bytes)
180
+ // Force incremental with several different sub-cap boundaries.
181
+ for (const boundary of [1, 7, 13, 64, 100]) {
182
+ const h = await calculateFileChecksum(file, boundary)
183
+ expect(h).toBe(expected)
184
+ }
185
+ })
186
+
187
+ test('maxSize of 0 or null falls back to the default boundary', async () => {
188
+ const file = new File([new Uint8Array([1, 2, 3])], 'tiny.bin')
189
+ const h = await calculateFileChecksum(file, 0)
190
+ expect(h).toMatch(/^[0-9a-f]{64}$/)
191
+ })
192
+ })