@gentleduck/upload 0.2.6 → 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 (136) hide show
  1. package/README.md +170 -193
  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 -51
  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/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  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/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -1,16 +1,25 @@
1
- import type { UploadConfig } from '../../client'
2
- import type { RejectReason } from '../../contracts'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { Engine } from '../engine.types'
3
3
  import { validateFile } from './file'
4
4
 
5
+ /**
6
+ * Validates an intent object from the backend.
7
+ * Ensures required fields are present and URLs are valid.
8
+ *
9
+ * @param intent - Intent object to validate
10
+ * @param strategy - Expected strategy type (from the intent itself)
11
+ *
12
+ * @returns {Error | null} Error if validation fails, null if valid
13
+ */
5
14
  export function validateFileList<P extends string>(
6
15
  files: File[],
7
16
  purpose: P,
8
- config: UploadConfig<P>,
17
+ config: Engine.Config<P>,
9
18
  existingCount: number = 0,
10
- ): { valid: File[]; rejected: Array<{ file: File; reason: RejectReason }> } {
19
+ ): { valid: File[]; rejected: Array<{ file: File; reason: Contracts.Validation.Rejection }> } {
11
20
  const rules = config.validation?.[purpose]
12
21
  const valid: File[] = []
13
- const rejected: Array<{ file: File; reason: RejectReason }> = []
22
+ const rejected: Array<{ file: File; reason: Contracts.Validation.Rejection }> = []
14
23
 
15
24
  // Calculate remaining slots based on maxFiles limit
16
25
  const maxFiles = rules?.maxFiles
@@ -38,13 +47,3 @@ export function validateFileList<P extends string>(
38
47
 
39
48
  return { valid, rejected }
40
49
  }
41
-
42
- /**
43
- * Validates an intent object from the backend.
44
- * Ensures required fields are present and URLs are valid.
45
- *
46
- * @param intent - Intent object to validate
47
- * @param strategy - Expected strategy type (from the intent itself)
48
- *
49
- * @returns {Error | null} Error if validation fails, null if valid
50
- */
@@ -0,0 +1,39 @@
1
+ import type { Contracts } from '../../contracts'
2
+ import { __mimeWarnings, mimeMatches, sniffMime } from '../../utils/mime-sniff'
3
+
4
+ /**
5
+ * Cross-check the claimed `file.type` against the file's magic bytes. Reads the
6
+ * first 16 bytes only (the longest signature {@link sniffMime} handles is 12).
7
+ *
8
+ * @returns A `mime_mismatch` reject reason when `strict` is `true` and the
9
+ * sniff disagrees. Returns `null` (allow) when the sniff is inconclusive, the
10
+ * claim matches, or `strict` is `false` — in the non-strict case a one-time
11
+ * warning is logged per mismatched pair. Empty files resolve to `null`.
12
+ */
13
+ export async function validateMimeSignature(
14
+ file: File,
15
+ strict: boolean,
16
+ ): Promise<Contracts.Validation.Rejection | null> {
17
+ if (file.size === 0) return null
18
+ // 16 bytes covers every recognised signature (longest is 12).
19
+ const head = await file.slice(0, 16).arrayBuffer()
20
+ const sniffed = sniffMime(new Uint8Array(head))
21
+ if (sniffed === null) return null
22
+ const claimed = file.type || ''
23
+ if (mimeMatches(claimed, sniffed)) return null
24
+
25
+ if (strict) {
26
+ return { code: 'mime_mismatch', claimed: claimed || '<empty>', sniffed }
27
+ }
28
+
29
+ const key = `${claimed || '<empty>'}::${sniffed}`
30
+ if (!__mimeWarnings.has(key)) {
31
+ __mimeWarnings.add(key)
32
+ // Filename is omitted from the warning: it is untrusted input.
33
+ console.warn(
34
+ `[duck-upload] MIME mismatch: claimed "${claimed || '<empty>'}" but bytes look like "${sniffed}". ` +
35
+ 'Set `strictMimeMatch: true` to reject these files.',
36
+ )
37
+ }
38
+ return null
39
+ }
@@ -0,0 +1,271 @@
1
+ import type { Contracts } from './contracts'
2
+
3
+ /**
4
+ * Structured extra context properties for upload errors.
5
+ * @author wildduck2 <https://github.com/wildduck2>
6
+ */
7
+ export type UploadErrorContext = Record<string, unknown>
8
+
9
+ const STATIC_MESSAGES: Record<string, string> = {
10
+ intent_failed: 'upload intent request failed',
11
+ upload_failed: 'upload transfer failed',
12
+ complete_failed: 'upload finalize failed',
13
+ validation_failed: 'file validation failed',
14
+ strategy_missing: 'upload strategy not found',
15
+ aborted: 'upload aborted',
16
+ network: 'network error occurred',
17
+ http: 'http request failed',
18
+ timeout: 'request timed out',
19
+ auth: 'authentication failed',
20
+ rate_limit: 'rate limit exceeded',
21
+ server: 'server error occurred',
22
+ unknown: 'unknown upload error',
23
+ }
24
+
25
+ /**
26
+ * Base typed error thrown by the upload engine.
27
+ *
28
+ * Always carries a stable error classification `code` and an immutable, static
29
+ * `message`. Attic strings (like raw input details) live under `context` or subclass-specific fields.
30
+ * @author wildduck2 <https://github.com/wildduck2>
31
+ */
32
+ export class UploadEngineError extends Error {
33
+ /** Stable machine-readable error code. */
34
+ readonly code: string
35
+ /** Structured context details. */
36
+ readonly context?: UploadErrorContext
37
+ /** Original error, if any, mapped per ES2022. */
38
+ override readonly cause?: unknown
39
+ /** Flag specifying whether the error recovery system should schedule retries. */
40
+ readonly retryable: boolean
41
+
42
+ constructor(
43
+ code: string,
44
+ init?: {
45
+ message?: string | undefined
46
+ context?: UploadErrorContext | undefined
47
+ cause?: unknown
48
+ retryable?: boolean | undefined
49
+ },
50
+ ) {
51
+ const msg = init?.message ?? STATIC_MESSAGES[code] ?? `upload error (${code})`
52
+ super(msg)
53
+ this.name = 'UploadEngineError'
54
+ this.code = code
55
+ this.retryable = init?.retryable ?? false
56
+ if (init?.context) this.context = init.context
57
+ if (init?.cause !== undefined) this.cause = init.cause
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Error thrown when file validation rules fail.
63
+ * @author wildduck2 <https://github.com/wildduck2>
64
+ */
65
+ export class UploadValidationError extends UploadEngineError {
66
+ /** Structured rejection reason details. */
67
+ readonly reason: Contracts.Validation.Rejection
68
+
69
+ constructor(
70
+ reason: Contracts.Validation.Rejection,
71
+ message?: string,
72
+ init?: { cause?: unknown; context?: UploadErrorContext },
73
+ ) {
74
+ super('validation_failed', {
75
+ message: message ?? `File validation failed: ${reason.code}`,
76
+ retryable: false,
77
+ cause: init?.cause,
78
+ context: init?.context,
79
+ })
80
+ this.name = 'UploadValidationError'
81
+ this.reason = reason
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Error thrown when an upload strategy identifier is not registered.
87
+ * @author wildduck2 <https://github.com/wildduck2>
88
+ */
89
+ export class UploadStrategyMissingError extends UploadEngineError {
90
+ /** Unregistered strategy identifier. */
91
+ readonly strategy: string
92
+
93
+ constructor(strategy: string, message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
94
+ super('strategy_missing', {
95
+ message: message ?? `Upload strategy "${strategy}" is not registered`,
96
+ retryable: false,
97
+ cause: init?.cause,
98
+ context: init?.context,
99
+ })
100
+ this.name = 'UploadStrategyMissingError'
101
+ this.strategy = strategy
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Error thrown when an upload is aborted (paused or canceled).
107
+ * @author wildduck2 <https://github.com/wildduck2>
108
+ */
109
+ export class UploadAbortError extends UploadEngineError {
110
+ /** Reason context for the abort. */
111
+ readonly reason: 'pause' | 'cancel' | 'unknown'
112
+
113
+ constructor(
114
+ reason: 'pause' | 'cancel' | 'unknown',
115
+ message?: string,
116
+ init?: { cause?: unknown; context?: UploadErrorContext },
117
+ ) {
118
+ super('aborted', {
119
+ message: message ?? 'Upload aborted',
120
+ retryable: false,
121
+ cause: init?.cause,
122
+ context: init?.context,
123
+ })
124
+ this.name = 'UploadAbortError'
125
+ this.reason = reason
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Error thrown on transport-level connection or fetch failures.
131
+ * @author wildduck2 <https://github.com/wildduck2>
132
+ */
133
+ export class UploadNetworkError extends UploadEngineError {
134
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
135
+ super('network', {
136
+ message: message ?? 'Network error occurred',
137
+ retryable: true,
138
+ cause: init?.cause,
139
+ context: init?.context,
140
+ })
141
+ this.name = 'UploadNetworkError'
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Error thrown when a non-2xx HTTP status response is returned.
147
+ * @author wildduck2 <https://github.com/wildduck2>
148
+ */
149
+ export class UploadHttpError extends UploadEngineError {
150
+ /** HTTP response status code. */
151
+ readonly status: number
152
+ /** HTTP response status text message. */
153
+ readonly statusText?: string
154
+
155
+ constructor(
156
+ status: number,
157
+ message?: string,
158
+ init?: {
159
+ statusText?: string | undefined
160
+ cause?: unknown
161
+ context?: UploadErrorContext | undefined
162
+ retryable?: boolean | undefined
163
+ },
164
+ ) {
165
+ const isRetryable = init?.retryable ?? (status >= 500 || status === 429)
166
+ super('http', {
167
+ message: message ?? `HTTP request failed with status ${status}`,
168
+ retryable: isRetryable,
169
+ cause: init?.cause,
170
+ context: init?.context,
171
+ })
172
+ this.name = 'UploadHttpError'
173
+ this.status = status
174
+ if (init?.statusText) this.statusText = init.statusText
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Error thrown when a timeout occurs during network operations.
180
+ * @author wildduck2 <https://github.com/wildduck2>
181
+ */
182
+ export class UploadTimeoutError extends UploadEngineError {
183
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
184
+ super('timeout', {
185
+ message: message ?? 'Request timed out',
186
+ retryable: true,
187
+ cause: init?.cause,
188
+ context: init?.context,
189
+ })
190
+ this.name = 'UploadTimeoutError'
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Error thrown on authentication / authorization failures.
196
+ * @author wildduck2 <https://github.com/wildduck2>
197
+ */
198
+ export class UploadAuthError extends UploadEngineError {
199
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
200
+ super('auth', {
201
+ message: message ?? 'Authentication failed',
202
+ retryable: false,
203
+ cause: init?.cause,
204
+ context: init?.context,
205
+ })
206
+ this.name = 'UploadAuthError'
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Error thrown when the endpoint indicates rate limits are exceeded.
212
+ * @author wildduck2 <https://github.com/wildduck2>
213
+ */
214
+ export class UploadRateLimitError extends UploadEngineError {
215
+ /** Optional delay value recommended by the server before retry. */
216
+ readonly retryAfterMs?: number
217
+
218
+ constructor(message?: string, init?: { retryAfterMs?: number; cause?: unknown; context?: UploadErrorContext }) {
219
+ super('rate_limit', {
220
+ message: message ?? 'Rate limit exceeded',
221
+ retryable: true,
222
+ cause: init?.cause,
223
+ context: init?.context,
224
+ })
225
+ this.name = 'UploadRateLimitError'
226
+ if (init?.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Error thrown when custom backend/server validation errors are reported.
232
+ * @author wildduck2 <https://github.com/wildduck2>
233
+ */
234
+ export class UploadServerError extends UploadEngineError {
235
+ /** Server-defined diagnostic code. */
236
+ readonly serverCode?: string
237
+
238
+ constructor(message?: string, init?: { serverCode?: string; cause?: unknown; context?: UploadErrorContext }) {
239
+ super('server', {
240
+ message: message ?? 'Server error occurred',
241
+ retryable: true,
242
+ cause: init?.cause,
243
+ context: init?.context,
244
+ })
245
+ this.name = 'UploadServerError'
246
+ if (init?.serverCode) this.serverCode = init.serverCode
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Fallback error class for unclassified or legacy throws.
252
+ * @author wildduck2 <https://github.com/wildduck2>
253
+ */
254
+ export class UploadUnknownError extends UploadEngineError {
255
+ constructor(message?: string, init?: { cause?: unknown; context?: UploadErrorContext }) {
256
+ super('unknown', {
257
+ message: message ?? 'Unknown upload error',
258
+ retryable: false,
259
+ cause: init?.cause,
260
+ context: init?.context,
261
+ })
262
+ this.name = 'UploadUnknownError'
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Runtime error stored on items and passed through engine events.
268
+ * Plain contract shapes and {@link UploadEngineError} subclasses are both valid.
269
+ * @author wildduck2 <https://github.com/wildduck2>
270
+ */
271
+ export type UploadError = Contracts.Errors.Error | UploadEngineError
package/src/core/index.ts CHANGED
@@ -1,5 +1,28 @@
1
- export * from './client'
2
- export * from './contracts'
3
- export * from './engine'
4
- export * from './persistence'
5
- export * from './utils'
1
+ export { resolveUploadConfig } from './client'
2
+ export type { Contracts, Transport } from './contracts'
3
+ export { createFetchTransport, createXHRTransport } from './contracts'
4
+ export type { Store } from './engine'
5
+ export { createUploadStore, Engine } from './engine'
6
+ export type { UploadError, UploadErrorContext } from './errors'
7
+ export {
8
+ UploadAbortError,
9
+ UploadAuthError,
10
+ UploadEngineError,
11
+ UploadHttpError,
12
+ UploadNetworkError,
13
+ UploadRateLimitError,
14
+ UploadServerError,
15
+ UploadStrategyMissingError,
16
+ UploadTimeoutError,
17
+ UploadUnknownError,
18
+ UploadValidationError,
19
+ } from './errors'
20
+ export type { UploadPersistence } from './persistence'
21
+ export {
22
+ createMemoryAdapter,
23
+ deserializeSnapshot,
24
+ IndexedDBAdapter,
25
+ LocalStorageAdapter,
26
+ MemoryAdapter,
27
+ serializeSnapshot,
28
+ } from './persistence'
@@ -0,0 +1,100 @@
1
+ /**
2
+ * NaN-bypass defense for the persistence deserializer. A persisted
3
+ * `size: NaN`, `lastModified: NaN`, `pct: NaN`, or negative byte count
4
+ * would land in the deserialized state and break downstream math
5
+ * (`uploadedBytes / totalBytes` → NaN, comparisons silently false).
6
+ * Every numeric field must pass `Number.isFinite` + non-negative.
7
+ */
8
+
9
+ import { describe, expect, test } from 'vitest'
10
+ import { deserializeSnapshot } from '../persistence'
11
+
12
+ function makeOpts(strategy = 'post') {
13
+ return {
14
+ isPurpose: (v: string): v is string => typeof v === 'string',
15
+ isIntent: (v: unknown): v is { strategy: string; fileId: string } =>
16
+ typeof v === 'object' && v !== null && 'strategy' in v,
17
+ hasStrategy: (v: string) => v === strategy,
18
+ }
19
+ }
20
+
21
+ const okItem = {
22
+ id: 'u1',
23
+ purpose: 'avatar',
24
+ status: 'paused',
25
+ file: { name: 'a.txt', size: 100, type: 'text/plain', lastModified: 1700000000 },
26
+ intent: { strategy: 'post', fileId: 'f' },
27
+ cursor: { strategy: 'post' },
28
+ progress: { uploadedBytes: 50, totalBytes: 100, pct: 50 },
29
+ }
30
+
31
+ describe('deserializeSnapshot - NaN-bypass defense', () => {
32
+ test('NaN version rejects snapshot', () => {
33
+ const raw = { version: Number.NaN, createdAt: 1, items: { u1: okItem } }
34
+ expect(deserializeSnapshot(raw, makeOpts())).toBeNull()
35
+ })
36
+
37
+ test('Infinity createdAt rejects snapshot', () => {
38
+ const raw = { version: 1, createdAt: Number.POSITIVE_INFINITY, items: { u1: okItem } }
39
+ expect(deserializeSnapshot(raw, makeOpts())).toBeNull()
40
+ })
41
+
42
+ test('NaN file.size drops the item (other items still load)', () => {
43
+ const raw = {
44
+ version: 1,
45
+ createdAt: 1,
46
+ items: {
47
+ u1: { ...okItem, file: { ...okItem.file, size: Number.NaN } },
48
+ u2: { ...okItem, id: 'u2' },
49
+ },
50
+ }
51
+ const state = deserializeSnapshot(raw, makeOpts())
52
+ expect(state).not.toBeNull()
53
+ expect(state?.items.has('u1')).toBe(false)
54
+ expect(state?.items.has('u2')).toBe(true)
55
+ })
56
+
57
+ test('negative size drops the item', () => {
58
+ const raw = {
59
+ version: 1,
60
+ createdAt: 1,
61
+ items: { u1: { ...okItem, file: { ...okItem.file, size: -1 } } },
62
+ }
63
+ const state = deserializeSnapshot(raw, makeOpts())
64
+ expect(state?.items.has('u1')).toBe(false)
65
+ })
66
+
67
+ test('NaN lastModified drops the item', () => {
68
+ const raw = {
69
+ version: 1,
70
+ createdAt: 1,
71
+ items: { u1: { ...okItem, file: { ...okItem.file, lastModified: Number.NaN } } },
72
+ }
73
+ const state = deserializeSnapshot(raw, makeOpts())
74
+ expect(state?.items.has('u1')).toBe(false)
75
+ })
76
+
77
+ test('NaN progress.uploadedBytes drops progress (item still loads with zero)', () => {
78
+ const raw = {
79
+ version: 1,
80
+ createdAt: 1,
81
+ items: {
82
+ u1: { ...okItem, progress: { ...okItem.progress, uploadedBytes: Number.NaN } },
83
+ },
84
+ }
85
+ const state = deserializeSnapshot(raw, makeOpts())
86
+ expect(state?.items.has('u1')).toBe(true)
87
+ const item = state?.items.get('u1') as { progress?: { uploadedBytes: number } } | undefined
88
+ expect(item?.progress?.uploadedBytes).toBe(0)
89
+ })
90
+
91
+ test('empty MIME type is allowed (browsers may return empty for unknown)', () => {
92
+ const raw = {
93
+ version: 1,
94
+ createdAt: 1,
95
+ items: { u1: { ...okItem, file: { ...okItem.file, type: '' } } },
96
+ }
97
+ const state = deserializeSnapshot(raw, makeOpts())
98
+ expect(state?.items.has('u1')).toBe(true)
99
+ })
100
+ })
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Defense against prototype-pollution via persisted snapshots. Round-trip
3
+ * tests live in `src/__test__/persistence.test.ts`; this file is scoped to
4
+ * security regressions.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest'
7
+ import { isRecord } from '../../utils/guards'
8
+ import { LocalStorageAdapter } from '../adapters.local'
9
+
10
+ type LocalStorageStub = {
11
+ store: Record<string, string>
12
+ getItem: (key: string) => string | null
13
+ setItem: (key: string, value: string) => void
14
+ removeItem: (key: string) => void
15
+ clear: () => void
16
+ }
17
+
18
+ function installLocalStorageStub(): LocalStorageStub {
19
+ const stub: LocalStorageStub = {
20
+ store: {},
21
+ getItem(key) {
22
+ return Object.prototype.hasOwnProperty.call(this.store, key) ? (this.store[key] ?? null) : null
23
+ },
24
+ setItem(key, value) {
25
+ this.store[key] = String(value)
26
+ },
27
+ removeItem(key) {
28
+ delete this.store[key]
29
+ },
30
+ clear() {
31
+ this.store = {}
32
+ },
33
+ }
34
+ ;(globalThis as unknown as { localStorage: LocalStorageStub }).localStorage = stub
35
+ return stub
36
+ }
37
+
38
+ describe('LocalStorageAdapter prototype-pollution defense', () => {
39
+ let stub: LocalStorageStub
40
+
41
+ beforeEach(() => {
42
+ stub = installLocalStorageStub()
43
+ })
44
+
45
+ afterEach(() => {
46
+ // Defensive cleanup in case a test forgot to.
47
+ delete (Object.prototype as Record<string, unknown>)['polluted']
48
+ delete (Object.prototype as Record<string, unknown>)['rce']
49
+ })
50
+
51
+ test('a {"__proto__":{"polluted":true}} payload does NOT pollute Object.prototype after load', async () => {
52
+ stub.store['snap'] = '{"__proto__":{"polluted":true}}'
53
+ const loaded = await LocalStorageAdapter.load('snap')
54
+ expect(loaded).not.toBeNull()
55
+ expect(({} as Record<string, unknown>)['polluted']).toBeUndefined()
56
+ expect(Object.prototype.hasOwnProperty.call(loaded as object, '__proto__')).toBe(false)
57
+ })
58
+
59
+ test('a {"constructor":{"prototype":{"polluted":true}}} payload does NOT pollute', async () => {
60
+ stub.store['snap'] = '{"constructor":{"prototype":{"rce":true}}}'
61
+ const loaded = await LocalStorageAdapter.load('snap')
62
+ expect(loaded).not.toBeNull()
63
+ expect(({} as Record<string, unknown>)['rce']).toBeUndefined()
64
+ expect(Object.prototype.hasOwnProperty.call(loaded as object, 'constructor')).toBe(false)
65
+ })
66
+
67
+ test('array-shaped persisted value is rejected by isRecord (no spread into state)', async () => {
68
+ stub.store['snap'] = '[1,2,3]'
69
+ const loaded = await LocalStorageAdapter.load('snap')
70
+ expect(Array.isArray(loaded)).toBe(true)
71
+ expect(isRecord(loaded)).toBe(false)
72
+ })
73
+
74
+ test('happy path: a plain-object snapshot hydrates fine', async () => {
75
+ stub.store['snap'] = JSON.stringify({ version: 1, createdAt: 100, items: { x: { id: 'x', v: 1 } } })
76
+ const loaded = (await LocalStorageAdapter.load('snap')) as Record<string, unknown>
77
+ expect(loaded).toEqual({ version: 1, createdAt: 100, items: { x: { id: 'x', v: 1 } } })
78
+ expect(isRecord(loaded)).toBe(true)
79
+ })
80
+
81
+ test('malformed JSON returns null', async () => {
82
+ stub.store['snap'] = '{not-json'
83
+ expect(await LocalStorageAdapter.load('snap')).toBeNull()
84
+ })
85
+ })
@@ -1,4 +1,5 @@
1
- import type { PersistenceAdapter } from './persistence.types'
1
+ import { stripDangerousKeys } from '../utils/guards'
2
+ import type { UploadPersistence } from './persistence.types'
2
3
 
3
4
  const DB_NAME = 'upload-engine'
4
5
  const STORE = 'snapshots'
@@ -33,7 +34,12 @@ function txDone(tx: IDBTransaction): Promise<void> {
33
34
  })
34
35
  }
35
36
 
36
- export const IndexedDBAdapter: PersistenceAdapter = {
37
+ /**
38
+ * Persistence adapter backed by IndexedDB, for durable snapshot storage in the
39
+ * browser without the size limits of `localStorage`.
40
+ * @author wildduck2 <https://github.com/wildduck2>
41
+ */
42
+ export const IndexedDBAdapter: UploadPersistence.Adapter = {
37
43
  async load(key) {
38
44
  if (typeof indexedDB === 'undefined') return null
39
45
  const db = await openDB()
@@ -43,7 +49,7 @@ export const IndexedDBAdapter: PersistenceAdapter = {
43
49
  try {
44
50
  const value = await reqToPromise(store.get(key))
45
51
  await txDone(tx)
46
- return value ?? null
52
+ return value === undefined || value === null ? null : stripDangerousKeys(value)
47
53
  } catch {
48
54
  try {
49
55
  await txDone(tx)
@@ -1,12 +1,18 @@
1
- import type { PersistenceAdapter } from './persistence.types'
1
+ import { stripDangerousKeys } from '../utils/guards'
2
+ import type { UploadPersistence } from './persistence.types'
2
3
 
3
- export const LocalStorageAdapter: PersistenceAdapter = {
4
+ /**
5
+ * Persistence adapter backed by `localStorage`. Synchronous and simple; best
6
+ * suited to small snapshots.
7
+ * @author wildduck2 <https://github.com/wildduck2>
8
+ */
9
+ export const LocalStorageAdapter: UploadPersistence.Adapter = {
4
10
  load(key) {
5
11
  if (typeof localStorage === 'undefined') return null
6
12
  const raw = localStorage.getItem(key)
7
13
  if (!raw) return null
8
14
  try {
9
- return JSON.parse(raw)
15
+ return stripDangerousKeys(JSON.parse(raw))
10
16
  } catch {
11
17
  return null
12
18
  }
@@ -1,15 +1,37 @@
1
- import type { PersistenceAdapter } from './persistence.types'
1
+ import type { UploadPersistence } from './persistence.types'
2
2
 
3
- const store = new Map<string, unknown>()
4
-
5
- export const MemoryAdapter: PersistenceAdapter = {
6
- load(key) {
7
- return store.get(key) ?? null
8
- },
9
- save(key, snapshot) {
10
- store.set(key, snapshot)
11
- },
12
- clear(key) {
13
- store.delete(key)
14
- },
3
+ /**
4
+ * Create an in-memory {@link PersistenceAdapter} backed by a fresh `Map`.
5
+ *
6
+ * Each call returns an isolated store. Multi-engine and multi-tenant
7
+ * deployments must use the factory so engine instances cannot read or
8
+ * overwrite each other's snapshots.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * const adapter = createMemoryAdapter()
13
+ * createUploadStore({ persistence: { adapter, key: 'tenant-a' } })
14
+ * ```
15
+ * @author wildduck2 <https://github.com/wildduck2>
16
+ */
17
+ export function createMemoryAdapter(): UploadPersistence.Adapter {
18
+ const store = new Map<string, unknown>()
19
+ return {
20
+ load(key) {
21
+ return store.get(key) ?? null
22
+ },
23
+ save(key, snapshot) {
24
+ store.set(key, snapshot)
25
+ },
26
+ clear(key) {
27
+ store.delete(key)
28
+ },
29
+ }
15
30
  }
31
+
32
+ /**
33
+ * Shared in-memory adapter instance. Convenient for single-engine apps; use
34
+ * {@link createMemoryAdapter} when isolation between engines is required.
35
+ * @author wildduck2 <https://github.com/wildduck2>
36
+ */
37
+ export const MemoryAdapter = createMemoryAdapter()