@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,56 +1,2 @@
1
- import { f as IntentMap, l as UploadResultBase, n as StrategyRegistry, o as CursorMap, r as UploadStrategy, t as StrategyCtx } from "../strategy.types-gg9ZStRe.js";
2
-
3
- //#region src/strategies/multipart/index.d.ts
4
- type MultipartIntent = {
5
- strategy: 'multipart';
6
- fileId: string;
7
- uploadId: string;
8
- partSize: number;
9
- partCount: number;
10
- parts?: Array<{
11
- partNumber: number;
12
- url: string;
13
- headers?: Record<string, string>;
14
- }>;
15
- };
16
- type MultipartCursor = {
17
- done: Array<{
18
- partNumber: number;
19
- etag: string;
20
- size: number;
21
- }>;
22
- /**
23
- * Marks that the multipart session was completed on the backend (parts assembled).
24
- * This prevents re-sending completeMultipart on resume.
25
- */
26
- completed?: true;
27
- };
28
- declare function multipartStrategy<M extends IntentMap & {
29
- multipart: MultipartIntent;
30
- }, C extends CursorMap<M> & {
31
- multipart?: MultipartCursor;
32
- }, P extends string = string, R extends UploadResultBase = UploadResultBase>(opts?: {
33
- maxPartConcurrency?: number;
34
- }): UploadStrategy<M, C, P, R, 'multipart'>;
35
- //#endregion
36
- //#region src/strategies/post/index.d.ts
37
- type PostIntent = {
38
- strategy: 'post';
39
- fileId: string; /** Presigned POST URL (form action). */
40
- url: string; /** Form fields to include before the file. */
41
- fields: Record<string, string>; /** Optional expiry of the presigned URL. */
42
- expiresAt?: string;
43
- };
44
- type PostCursor = {
45
- /** Bytes uploaded — progress display only; not used for resume. */uploadedBytes: number;
46
- };
47
- declare function PostStrategy<M extends IntentMap & {
48
- post: PostIntent;
49
- }, C extends CursorMap<M> & {
50
- post?: PostCursor;
51
- }, P extends string = string, R extends UploadResultBase = UploadResultBase>(): UploadStrategy<M, C, P, R, 'post'>;
52
- //#endregion
53
- //#region src/strategies/registry/index.d.ts
54
- declare function createStrategyRegistry<M extends IntentMap, C extends CursorMap<M>, P extends string = string, R extends UploadResultBase = UploadResultBase>(): StrategyRegistry<M, C, P, R>;
55
- //#endregion
56
- export { MultipartCursor, MultipartIntent, PostCursor, PostIntent, PostStrategy, type StrategyCtx, type StrategyRegistry, type UploadStrategy, createStrategyRegistry, multipartStrategy };
1
+ import { a as PutStrategy, c as MultipartStrategy, i as PutIntent, l as multipartStrategy, n as TusStrategy, o as PostIntent, r as createStrategyRegistry, s as PostStrategy, t as TusIntent } from "../index-KROXtLQh.js";
2
+ export { MultipartStrategy, PostIntent, PostStrategy, PutIntent, PutStrategy, TusIntent, TusStrategy, createStrategyRegistry, multipartStrategy };
@@ -1 +1 @@
1
- import{t as e}from"../async-DxRWu4le.js";function t(e){return e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message))}function n(n){let r=Math.max(1,n?.maxPartConcurrency??4);return{id:`multipart`,resumable:!0,async start(n){let i=n.intent,a=n.file.size,o=Math.max(1,i.partSize),s=Math.max(1,i.partCount??Math.ceil(a/o)),c=n.readCursor(),l=new Map;if(c?.done)for(let e of c.done)l.set(e.partNumber,{etag:e.etag,size:e.size});let u=c?.completed===!0,d=new Map,f=0;for(let e of l.values())f+=e.size;let p=()=>{let e=0;for(let t of d.values())e+=t;n.reportProgress({uploadedBytes:f+e,totalBytes:a})},m=[];for(let e=1;e<=s;e++){if(l.has(e))continue;let t=(e-1)*o,n=Math.min(a,t+o);m.push({partNumber:e,start:t,end:n})}if(m.length===0){u||await y(),p();return}let h=m.slice(),g=new Set,_=async e=>{let t=i.parts?.find(t=>t.partNumber===e);if(t)return t;if(!n.api.multipart?.signPart)throw Error(`multipart.signPart is missing in UploadApi. Implement it to call your backend sign-part endpoint.`);let r=await n.api.multipart.signPart({fileId:i.fileId,uploadId:i.uploadId,partNumber:e},{signal:n.signal});return{partNumber:e,url:r.url,headers:r.headers}},v=async(r,i=0)=>{try{let e=n.file.slice(r.start,r.end),t=e.size,i=await _(r.partNumber),a=await n.transport.put({url:i.url,body:e,headers:i.headers,signal:n.signal,onProgress:e=>{d.set(r.partNumber,e),p()}});d.delete(r.partNumber);let o=a.etag;if(!o)throw Error(`Missing ETag from upload part response. Ensure MinIO/S3 CORS exposes ETag (Access-Control-Expose-Headers: ETag).`);f+=t,l.set(r.partNumber,{etag:o,size:t});let s={done:Array.from(l.entries()).map(([e,t])=>({partNumber:e,etag:t.etag,size:t.size})).sort((e,t)=>e.partNumber-t.partNumber)};n.persistCursor(s),p()}catch(a){if(d.delete(r.partNumber),n.signal?.aborted||t(a))throw a;if(i<3){let t=a instanceof Error?a.message:String(a);if(/network/i.test(t)||/timeout/i.test(t)||/5\d\d/.test(t)||/ECONNRESET/i.test(t)||/EHOSTUNREACH/i.test(t))return await e(2**i*500),v(r,i+1)}throw a}};for(;h.length>0||g.size>0;){for(;h.length>0&&g.size<r;){let e=h.shift();if(!e)break;let t,n=v(e).finally(()=>{t&&g.delete(t)});t=n,g.add(n)}g.size>0&&await Promise.race(g)}await y(),p();async function y(){if(u)return;if(!n.api.multipart?.completeMultipart)throw Error(`multipart.completeMultipart is missing in UploadApi. Implement it to call your backend complete endpoint.`);let e=Array.from(l.entries()).map(([e,t])=>({partNumber:e,etag:t.etag})).sort((e,t)=>e.partNumber-t.partNumber);if(e.length!==s)throw Error(`Cannot complete multipart: expected ${s} parts, got ${e.length}`);await n.api.multipart.completeMultipart({fileId:i.fileId,uploadId:i.uploadId,parts:e},{signal:n.signal});let t={done:Array.from(l.entries()).map(([e,t])=>({partNumber:e,etag:t.etag,size:t.size})).sort((e,t)=>e.partNumber-t.partNumber),completed:!0};n.persistCursor(t)}}}}function r(){return{id:`post`,resumable:!1,async start(e){let t=e.intent;if(!t.url)throw Error(`Direct strategy: intent missing url/uploadUrl`);await e.transport.postForm({url:t.url,file:e.file,fields:t.fields,filename:e.file.name,signal:e.signal,onProgress(t,n){e.reportProgress({uploadedBytes:t,totalBytes:n})}})}}}function i(){let e={};return{get(t){return e[t]},has(t){return Object.hasOwn(e,t)},set(t){e[t.id]=t}}}export{r as PostStrategy,i as createStrategyRegistry,n as multipartStrategy};
1
+ import{a as e,i as t,n,r,t as i}from"../strategies-CUF0s68j.js";export{t as PostStrategy,r as PutStrategy,i as TusStrategy,n as createStrategyRegistry,e as multipartStrategy};
@@ -0,0 +1 @@
1
+ import{d as e,m as t}from"./core-BVgloTiP.js";function n(e){let t=/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(e);if(!t||t[1]===void 0||t[2]===void 0)return null;let n=parseInt(t[1],16),r=parseInt(t[2],16);return!Number.isFinite(n)||!Number.isFinite(r)||n<0||n>65535||r<0||r>65535?null:`${n>>8&255}.${n&255}.${r>>8&255}.${r&255}`}function r(e){let t=e.startsWith(`[`)&&e.endsWith(`]`)?e.slice(1,-1):e;t.endsWith(`.`)&&(t=t.slice(0,-1));let i=/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(t);if(i){let e=Number(i[1]),t=Number(i[2]),n=Number(i[3]),r=Number(i[4]);return e>255||t>255||n>255||r>255?!1:e===127||e===10||e===192&&t===168||e===172&&t>=16&&t<=31||e===169&&t===254||e===0||e===100&&t>=64&&t<=127||e>=224&&e<=239||e>=240}if(t.includes(`:`)){let e=t.toLowerCase();if(e===`::1`||e===`0:0:0:0:0:0:0:1`||e===`::`||e===`0:0:0:0:0:0:0:0`||/^f[cd][0-9a-f]{0,2}:/.test(e)||/^fe[89ab][0-9a-f]?:/.test(e)||/^ff[0-9a-f]{0,2}:/.test(e))return!0;let i=null;if(e.startsWith(`::ffff:`)?i=e.slice(7):e.startsWith(`0:0:0:0:0:ffff:`)&&(i=e.slice(15)),i!==null){if(i.includes(`.`))return r(i);let e=n(i);return e?r(e):!1}if(e.startsWith(`::`)&&e.includes(`.`)){let t=e.slice(2);if(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.test(t))return r(t)}if(e.startsWith(`2002:`)){let t=/^2002:([0-9a-f]{1,4}):([0-9a-f]{1,4})(?::|$)/.exec(e);if(t){let e=n(`${t[1]}:${t[2]}`);if(e)return r(e)}}if(e.startsWith(`64:ff9b:`)||e.startsWith(`0064:ff9b:`)){let t=e.startsWith(`0064:`)?e.slice(10):e.slice(8);if(t.includes(`.`)){let e=/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(t);if(e?.[1]!==void 0)return r(e[1])}let i=t.split(`:`).filter(e=>e.length>0);if(i.length>=2){let e=n(`${i[i.length-2]}:${i[i.length-1]}`);if(e)return r(e)}}return!1}return!1}function i(e,t,n={}){if(typeof e!=`string`||e.length===0)throw Error(`${t} returned an invalid URL (empty or non-string)`);if(e.length>8192)throw Error(`${t} URL exceeds 8192-byte length cap`);if(e.includes(`..`))throw Error(`${t} URL contains forbidden ".." segment`);let i;try{i=new URL(e)}catch{throw Error(`${t} returned a malformed URL`)}if(i.protocol!==`http:`&&i.protocol!==`https:`)throw Error(`${t} URL has forbidden protocol "${i.protocol}"`);if(n.allowedHosts&&n.allowedHosts.length>0){let e=i.host.toLowerCase();if(!n.allowedHosts.map(e=>e.toLowerCase()).includes(e))throw Error(`${t} URL host is not in the configured allow-list`)}if(!n.allowPrivateHosts&&r(i.hostname))throw Error(`${t} URL points to a private/loopback host`)}function a(e){return e instanceof Error&&(e.name===`AbortError`||/abort/i.test(e.message))}function o(e){let t=e instanceof Error?e.message:String(e);return/network/i.test(t)||/timeout/i.test(t)||/5\d\d/.test(t)||/ECONNRESET/i.test(t)||/EHOSTUNREACH/i.test(t)}async function s(t,n={}){let r=Math.max(0,n.maxRetries??3),i=n.retryable??o,s=0;for(;;)try{return await t(s)}catch(t){if(n.signal?.aborted||a(t)||s>=r||!i(t))throw t;await e(2**s*500),s++}}let c=!1;function l(e,t={}){(!t.allowedHosts||t.allowedHosts.length===0)&&!c&&(c=!0,console.warn("[duck-upload] multipartStrategy: no `allowedHosts` configured. Signed part URLs will be host-unrestricted. Set MultipartStrategy.Config.allowedHosts to lock the upload host.")),i(e,`multipart.signPart`,t)}function u(e){let n=Math.max(1,e?.maxPartConcurrency??4),r=e?.allowedHosts??[],i=e?.allowPrivateHosts===!0;return{id:`multipart`,resumable:!0,async start(e){let a=e.intent,o=e.file.size,c=Math.max(1,a.partSize),u=Math.max(1,a.partCount??Math.ceil(o/c)),d=e.readCursor(),f=new Map;if(d?.done)for(let e of d.done)f.set(e.partNumber,{etag:e.etag,size:e.size});let p=d?.completed===!0,m=new Map,h=0;for(let e of f.values())h+=e.size;let g=()=>{let t=0;for(let e of m.values())t+=e;e.reportProgress({uploadedBytes:h+t,totalBytes:o})},_=[];for(let e=1;e<=u;e++){if(f.has(e))continue;let t=(e-1)*c,n=Math.min(o,t+c);_.push({partNumber:e,start:t,end:n})}if(_.length===0){p||await S(),g();return}let v=_.slice(),y=new Set,b=async n=>{let r=a.parts?.find(e=>e.partNumber===n);if(r)return r;if(!e.api.multipart?.signPart)throw new t(`validation_failed`,{message:`multipart.signPart is missing in UploadApi. Implement it to call your backend sign-part endpoint.`});let i=await e.api.multipart.signPart({fileId:a.fileId,uploadId:a.uploadId,partNumber:n,attempt:e.attempt},{localId:e.localId,purpose:e.purpose,file:e.file,intent:e.intent,signal:e.signal,fingerprint:e.fingerprint,attempt:e.attempt,createdAt:e.createdAt,steps:e.steps,meta:e.meta,lastError:e.lastError});return{partNumber:n,url:i.url,headers:i.headers}},x=n=>s(async()=>{try{let a=e.file.slice(n.start,n.end),o=a.size,s=await b(n.partNumber);l(s.url,{allowedHosts:r,allowPrivateHosts:i});let c=await e.transport.put({url:s.url,body:a,headers:s.headers??{},signal:e.signal,onProgress:e=>{m.set(n.partNumber,e),g()}});m.delete(n.partNumber);let u=c.etag;if(!u)throw new t(`upload_failed`,{message:`Missing ETag from upload part response. Ensure MinIO/S3 CORS exposes ETag (Access-Control-Expose-Headers: ETag).`});h+=o,f.set(n.partNumber,{etag:u,size:o});let d={done:Array.from(f.entries()).map(([e,t])=>({partNumber:e,etag:t.etag,size:t.size})).sort((e,t)=>e.partNumber-t.partNumber)};e.persistCursor(d),g()}catch(e){throw m.delete(n.partNumber),e}},{signal:e.signal});for(;v.length>0||y.size>0;){for(;v.length>0&&y.size<n;){let e=v.shift();if(!e)break;let t,n=x(e).finally(()=>{t&&y.delete(t)});t=n,y.add(n)}y.size>0&&await Promise.race(y)}await S(),g();async function S(){if(p)return;if(!e.api.multipart?.completeMultipart)throw new t(`validation_failed`,{message:`multipart.completeMultipart is missing in UploadApi. Implement it to call your backend complete endpoint.`});let n=Array.from(f.entries()).map(([e,t])=>({partNumber:e,etag:t.etag})).sort((e,t)=>e.partNumber-t.partNumber);if(n.length!==u)throw new t(`complete_failed`,{message:`Cannot complete multipart: expected ${u} parts, got ${n.length}`});await e.api.multipart.completeMultipart({fileId:a.fileId,uploadId:a.uploadId,parts:n},{localId:e.localId,purpose:e.purpose,file:e.file,intent:e.intent,signal:e.signal,fingerprint:e.fingerprint,attempt:e.attempt,createdAt:e.createdAt,steps:e.steps,meta:e.meta,lastError:e.lastError});let r={done:Array.from(f.entries()).map(([e,t])=>({partNumber:e,etag:t.etag,size:t.size})).sort((e,t)=>e.partNumber-t.partNumber),completed:!0};e.persistCursor(r)}}}}let d=!1;function f(e={}){let n=e.allowedHosts??[],r=e.allowPrivateHosts===!0;return{id:`post`,resumable:!1,async start(a){let o=a.intent;if(!o.url)throw new t(`validation_failed`,{message:`post.start: intent missing url`});n.length===0&&!d&&(d=!0,console.warn("[duck-upload] PostStrategy: no `allowedHosts` configured. Presigned POST URLs will be host-unrestricted. Set PostStrategy.Config.allowedHosts to lock the upload host.")),i(o.url,`post.intent`,{allowedHosts:n,allowPrivateHosts:r}),await s(()=>a.transport.postForm({url:o.url,file:a.file,fields:o.fields,filename:a.file.name,signal:a.signal,onProgress(e,t){a.reportProgress({uploadedBytes:e,totalBytes:t})}}),{signal:a.signal,...e.maxRetries===void 0?{}:{maxRetries:e.maxRetries}})}}}let p=!1;function m(e={}){let n=e.allowedHosts??[],r=e.allowPrivateHosts===!0;return{id:`put`,resumable:!1,async start(a){let o=a.intent;if(!o.url)throw new t(`validation_failed`,{message:`put.start: intent missing url`});n.length===0&&!p&&(p=!0,console.warn("[duck-upload] PutStrategy: no `allowedHosts` configured. Presigned PUT URLs will be host-unrestricted. Set PutStrategy.Config.allowedHosts to lock the upload host.")),i(o.url,`put.intent`,{allowedHosts:n,allowPrivateHosts:r});let c=a.file.size;await s(()=>a.transport.put({url:o.url,body:a.file,headers:o.headers??{},signal:a.signal,onProgress(e,t){a.reportProgress({uploadedBytes:e,totalBytes:t||c})}}),{signal:a.signal,...e.maxRetries===void 0?{}:{maxRetries:e.maxRetries}})}}}function h(){let e={};return{get(t){return e[t]},has(t){return Object.hasOwn(e,t)},set(t){e[t.id]=t}}}let g=!1;function _(e,t){let n=e?.[t]??e?.[t.toLowerCase()];if(n===void 0)return null;let r=Number(n);return Number.isInteger(r)&&r>=0?r:null}function v(e={}){let n=e.allowedHosts??[],r=e.allowPrivateHosts===!0,a=e.chunkSize,o=e.maxRetries===void 0?{}:{maxRetries:e.maxRetries};return{id:`tus`,resumable:!0,async start(e){let c=e.intent;if(!c.url)throw new t(`validation_failed`,{message:`tus.start: intent missing url`});n.length===0&&!g&&(g=!0,console.warn("[duck-upload] TusStrategy: no `allowedHosts` configured. tus upload URLs will be host-unrestricted. Set TusStrategy.Config.allowedHosts to lock the upload host.")),i(c.url,`tus.intent`,{allowedHosts:n,allowPrivateHosts:r});let l=c.url,u=e.file.size,d=Math.max(1,c.chunkSize??a??8388608),f={"Tus-Resumable":`1.0.0`,...c.headers??{}},p=e.readCursor()?.offset??0;if(e.transport.head){let t=e.transport.head;try{let n=_((await s(()=>t({url:l,headers:f,signal:e.signal}),{signal:e.signal,...o})).headers,`upload-offset`);n!==null&&(p=n)}catch(t){if(e.signal?.aborted||t instanceof Error&&t.name===`AbortError`)throw t}}for(p>u&&(p=u),e.reportProgress({uploadedBytes:p,totalBytes:u});p<u;){if(e.signal?.aborted)throw new DOMException(`The operation was aborted.`,`AbortError`);let n=p,r=Math.min(u,n+d),i=e.file.slice(n,r),a=_((await s(()=>e.transport.patch({url:l,body:i,headers:{...f,"Upload-Offset":String(n),"Content-Type":`application/offset+octet-stream`},signal:e.signal,onProgress:t=>{e.reportProgress({uploadedBytes:n+t,totalBytes:u})}}),{signal:e.signal,...o})).headers,`upload-offset`);if(p=a===null?r:a,p<=n)throw new t(`upload_failed`,{message:`tus: server did not advance Upload-Offset (stuck at ${p} of ${u}).`});e.persistCursor({offset:p}),e.reportProgress({uploadedBytes:p,totalBytes:u})}}}}export{u as a,f as i,h as n,m as r,v as t};
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@gentleduck/upload",
4
- "version": "0.2.6",
4
+ "version": "0.3.0",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
8
  "type": "module",
9
+ "sideEffects": false,
9
10
  "exports": {
10
11
  ".": {
11
12
  "types": "./dist/index.d.ts",
@@ -52,7 +53,7 @@
52
53
  "tsdown": "catalog:",
53
54
  "typescript": "catalog:",
54
55
  "vite": "catalog:",
55
- "vitest": "^4.0.18"
56
+ "vitest": "^5.0.0"
56
57
  },
57
58
  "engines": {
58
59
  "node": ">=22.0.0"
@@ -59,15 +59,15 @@ describe('createTypedEmitter', () => {
59
59
  const e = createTypedEmitter<Events>()
60
60
  const a: number[] = []
61
61
  const b: number[] = []
62
- e.on('foo', (p) => a.push(p.x))
63
- e.on('foo', (p) => b.push(p.x))
62
+ e.on('foo', (p: any) => a.push(p.x))
63
+ e.on('foo', (p: any) => b.push(p.x))
64
64
 
65
65
  for (let i = 0; i < 5; i++) e.emit('foo', { x: i })
66
66
  expect(a).toEqual([0, 1, 2, 3, 4])
67
67
  expect(b).toEqual([0, 1, 2, 3, 4])
68
68
 
69
69
  const c: number[] = []
70
- e.on('foo', (p) => c.push(p.x))
70
+ e.on('foo', (p: any) => c.push(p.x))
71
71
  e.emit('foo', { x: 99 })
72
72
  expect(c).toEqual([99])
73
73
  expect(a).toEqual([0, 1, 2, 3, 4, 99])
@@ -76,7 +76,7 @@ describe('createTypedEmitter', () => {
76
76
  test('off() with a non-subscribed callback is a no-op', () => {
77
77
  const e = createTypedEmitter<Events>()
78
78
  const tracker: number[] = []
79
- e.on('foo', (p) => tracker.push(p.x))
79
+ e.on('foo', (p: any) => tracker.push(p.x))
80
80
  const unrelated = (p: { x: number }) => tracker.push(-p.x)
81
81
  expect(() => e.off('foo', unrelated)).not.toThrow()
82
82
  e.emit('foo', { x: 7 })
@@ -11,7 +11,7 @@ describe('computeFingerprint', () => {
11
11
  expect(computeFingerprint(f)).toEqual({
12
12
  name: 'a.txt',
13
13
  size: 5,
14
- type: 'text/plain',
14
+ type: f.type,
15
15
  lastModified: 1234,
16
16
  })
17
17
  })
@@ -21,10 +21,20 @@ describe('isRecord', () => {
21
21
  expect(isRecord(true)).toBe(false)
22
22
  })
23
23
 
24
- // NOTE: the current implementation treats arrays as records (typeof === 'object').
25
- // Documented limitation: isRecord does not narrow out arrays.
26
- test('arrays are treated as records by the current implementation', () => {
27
- expect(isRecord([])).toBe(true)
28
- expect(isRecord([1, 2, 3])).toBe(true)
24
+ test('arrays are rejected', () => {
25
+ expect(isRecord([])).toBe(false)
26
+ expect(isRecord([1, 2, 3])).toBe(false)
27
+ })
28
+
29
+ test('exotic objects are rejected', () => {
30
+ expect(isRecord(new Date())).toBe(false)
31
+ expect(isRecord(new Map())).toBe(false)
32
+ expect(isRecord(new Set())).toBe(false)
33
+ class Foo {}
34
+ expect(isRecord(new Foo())).toBe(false)
35
+ })
36
+
37
+ test('null-prototype objects are accepted', () => {
38
+ expect(isRecord(Object.create(null))).toBe(true)
29
39
  })
30
40
  })
@@ -0,0 +1,67 @@
1
+ import { afterEach, describe, expect, test } from 'vitest'
2
+ import { generateId } from '../core/utils/id'
3
+
4
+ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
5
+
6
+ describe('generateId', () => {
7
+ test('starts with the `upload_` prefix', () => {
8
+ expect(generateId()).toMatch(/^upload_/)
9
+ })
10
+
11
+ test('embeds a v4 UUID after the timestamp', () => {
12
+ const id = generateId()
13
+ const match = id.match(/^upload_(\d+)_(.+)$/)
14
+ expect(match).not.toBeNull()
15
+ expect(match![2]).toMatch(UUID_V4)
16
+ })
17
+
18
+ test('produces 1000 unique IDs (collision-resistance smoke)', () => {
19
+ const set = new Set<string>()
20
+ for (let i = 0; i < 1000; i++) set.add(generateId())
21
+ expect(set.size).toBe(1000)
22
+ })
23
+
24
+ test('produces unique values across many calls', () => {
25
+ const set = new Set<string>()
26
+ for (let i = 0; i < 10_000; i++) set.add(generateId())
27
+ expect(set.size).toBe(10_000)
28
+ })
29
+ })
30
+
31
+ describe('generateId fallback path (no crypto.randomUUID)', () => {
32
+ const originalCrypto = (globalThis as { crypto?: Crypto }).crypto
33
+
34
+ afterEach(() => {
35
+ // Restore the original crypto reference for subsequent tests.
36
+ Object.defineProperty(globalThis, 'crypto', {
37
+ value: originalCrypto,
38
+ configurable: true,
39
+ writable: true,
40
+ })
41
+ })
42
+
43
+ test('falls back to getRandomValues and still produces a v4-shaped UUID', () => {
44
+ const fakeCrypto = {
45
+ // Simulate a runtime that lacks randomUUID but has getRandomValues.
46
+ getRandomValues: (arr: any) => originalCrypto!.getRandomValues(arr),
47
+ } as unknown as Crypto
48
+ Object.defineProperty(globalThis, 'crypto', {
49
+ value: fakeCrypto,
50
+ configurable: true,
51
+ writable: true,
52
+ })
53
+ const id = generateId()
54
+ const match = id.match(/^upload_\d+_(.+)$/)
55
+ expect(match).not.toBeNull()
56
+ expect(match![1]).toMatch(UUID_V4)
57
+ })
58
+
59
+ test('throws when neither randomUUID nor getRandomValues are available', () => {
60
+ Object.defineProperty(globalThis, 'crypto', {
61
+ value: {} as Crypto,
62
+ configurable: true,
63
+ writable: true,
64
+ })
65
+ expect(() => generateId()).toThrow(/CSPRNG/)
66
+ })
67
+ })
@@ -0,0 +1,142 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+ import { validateMimeSignature } from '../core/engine/validation/mime'
3
+ import { __mimeWarnings, mimeMatches, sniffMime } from '../core/utils/mime-sniff'
4
+
5
+ function bytes(...arr: number[]): Uint8Array<ArrayBuffer> {
6
+ return new Uint8Array(arr)
7
+ }
8
+
9
+ describe('sniffMime', () => {
10
+ test('detects JPEG (FF D8 FF)', () => {
11
+ expect(sniffMime(bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10))).toBe('image/jpeg')
12
+ })
13
+
14
+ test('detects PNG (89 50 4E 47 0D 0A 1A 0A)', () => {
15
+ expect(sniffMime(bytes(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a))).toBe('image/png')
16
+ })
17
+
18
+ test('detects GIF (47 49 46 38)', () => {
19
+ expect(sniffMime(bytes(0x47, 0x49, 0x46, 0x38, 0x39, 0x61))).toBe('image/gif')
20
+ })
21
+
22
+ test('detects PDF (%PDF)', () => {
23
+ expect(sniffMime(bytes(0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x34))).toBe('application/pdf')
24
+ })
25
+
26
+ test('detects ZIP family (PK..)', () => {
27
+ expect(sniffMime(bytes(0x50, 0x4b, 0x03, 0x04, 0x14, 0x00))).toBe('application/zip')
28
+ })
29
+
30
+ test('detects MP4 ftyp box', () => {
31
+ expect(sniffMime(bytes(0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d))).toBe('video/mp4')
32
+ })
33
+
34
+ test('detects WebP (RIFF....WEBP)', () => {
35
+ expect(sniffMime(bytes(0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50))).toBe('image/webp')
36
+ })
37
+
38
+ test('detects WebM/Matroska', () => {
39
+ expect(sniffMime(bytes(0x1a, 0x45, 0xdf, 0xa3, 0x9f, 0x42))).toBe('video/webm')
40
+ })
41
+
42
+ test('detects MP3 ID3 tag', () => {
43
+ expect(sniffMime(bytes(0x49, 0x44, 0x33, 0x04, 0x00, 0x00))).toBe('audio/mpeg')
44
+ })
45
+
46
+ test('detects MP3 raw frame sync (FF FB)', () => {
47
+ expect(sniffMime(bytes(0xff, 0xfb, 0x90, 0x00))).toBe('audio/mpeg')
48
+ })
49
+
50
+ test('returns null on unknown bytes', () => {
51
+ expect(sniffMime(bytes(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b))).toBeNull()
52
+ })
53
+
54
+ test('returns null on too-short buffers', () => {
55
+ expect(sniffMime(bytes(0xff))).toBeNull()
56
+ })
57
+ })
58
+
59
+ describe('mimeMatches', () => {
60
+ test('null sniff is always a match (unknown is not deny)', () => {
61
+ expect(mimeMatches('image/jpeg', null)).toBe(true)
62
+ })
63
+
64
+ test('exact match', () => {
65
+ expect(mimeMatches('image/jpeg', 'image/jpeg')).toBe(true)
66
+ })
67
+
68
+ test('empty / octet-stream claim is always a match (no claim made)', () => {
69
+ expect(mimeMatches('', 'image/jpeg')).toBe(true)
70
+ expect(mimeMatches('application/octet-stream', 'application/pdf')).toBe(true)
71
+ })
72
+
73
+ test('JPEG common aliases', () => {
74
+ expect(mimeMatches('image/jpg', 'image/jpeg')).toBe(true)
75
+ expect(mimeMatches('image/pjpeg', 'image/jpeg')).toBe(true)
76
+ })
77
+
78
+ test('Office DOCX is consistent with ZIP signature', () => {
79
+ expect(
80
+ mimeMatches('application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/zip'),
81
+ ).toBe(true)
82
+ })
83
+
84
+ test('JPEG bytes with claimed PDF type mismatch', () => {
85
+ expect(mimeMatches('application/pdf', 'image/jpeg')).toBe(false)
86
+ })
87
+ })
88
+
89
+ describe('validateMimeSignature', () => {
90
+ beforeEach(() => {
91
+ __mimeWarnings.clear()
92
+ })
93
+
94
+ test('JPEG bytes with claimed PDF type → rejected when strict', async () => {
95
+ const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], 'evil.pdf', { type: 'application/pdf' })
96
+ const reason = await validateMimeSignature(file, true)
97
+ expect(reason).toEqual({ code: 'mime_mismatch', claimed: 'application/pdf', sniffed: 'image/jpeg' })
98
+ })
99
+
100
+ test('JPEG bytes with claimed PDF type → null + console.warn when not strict', async () => {
101
+ const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], 'evil.pdf', { type: 'application/pdf' })
102
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
103
+ const reason = await validateMimeSignature(file, false)
104
+ expect(reason).toBeNull()
105
+ expect(warnSpy).toHaveBeenCalledTimes(1)
106
+ expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).toMatch(/MIME mismatch/)
107
+ // Filename must not appear in the warning: it is untrusted input.
108
+ expect(String(warnSpy.mock.calls[0]?.[0] ?? '')).not.toMatch(/evil\.pdf/)
109
+ warnSpy.mockRestore()
110
+ })
111
+
112
+ test('repeated identical mismatches only warn once', async () => {
113
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
114
+ for (let i = 0; i < 3; i++) {
115
+ const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], `f-${i}.pdf`, { type: 'application/pdf' })
116
+ await validateMimeSignature(file, false)
117
+ }
118
+ expect(warnSpy).toHaveBeenCalledTimes(1)
119
+ warnSpy.mockRestore()
120
+ })
121
+
122
+ test('matching JPEG+JPEG → accepted', async () => {
123
+ const file = new File([bytes(0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10)], 'ok.jpg', { type: 'image/jpeg' })
124
+ expect(await validateMimeSignature(file, true)).toBeNull()
125
+ })
126
+
127
+ test('unknown bytes → accepted (sniff returns null, no mismatch)', async () => {
128
+ const file = new File(
129
+ [bytes(0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b)],
130
+ 'mystery.dat',
131
+ {
132
+ type: 'application/pdf',
133
+ },
134
+ )
135
+ expect(await validateMimeSignature(file, true)).toBeNull()
136
+ })
137
+
138
+ test('empty file → null (defers to other validators)', async () => {
139
+ const file = new File([], 'empty.bin')
140
+ expect(await validateMimeSignature(file, true)).toBeNull()
141
+ })
142
+ })
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, test } from 'vitest'
2
- import type { UploadState } from '../core/engine/reducer'
3
- import { MemoryAdapter } from '../core/persistence'
2
+ import type { Engine } from '../core/engine/engine.types'
3
+ import { createMemoryAdapter, MemoryAdapter } from '../core/persistence'
4
4
  import { deserializeSnapshot, serializeSnapshot } from '../core/persistence/persistence'
5
- import type { PersistedSnapshot } from '../core/persistence/persistence.types'
5
+ import type { UploadPersistence } from '../core/persistence/persistence.types'
6
6
 
7
7
  type Intents = { post: { strategy: 'post'; fileId: string; url: string } }
8
8
  type Cursors = { post: { offset: number } }
@@ -22,7 +22,7 @@ const hasStrategy = (s: string) => s === 'post'
22
22
 
23
23
  describe('serializeSnapshot', () => {
24
24
  test('skips items without intents and terminal states', () => {
25
- const state: UploadState<Intents, Cursors, Purpose, Result> = {
25
+ const state: Engine.State<Intents, Cursors, Purpose, Result> = {
26
26
  items: new Map([
27
27
  [
28
28
  'a',
@@ -33,6 +33,8 @@ describe('serializeSnapshot', () => {
33
33
  fingerprint: { name: 'a', size: 1, type: 't', lastModified: 0 },
34
34
  file: new File(['a'], 'a'),
35
35
  createdAt: 0,
36
+ steps: [],
37
+ meta: {},
36
38
  },
37
39
  ],
38
40
  [
@@ -48,6 +50,8 @@ describe('serializeSnapshot', () => {
48
50
  result: { fileId: 'b', key: 'k/b' },
49
51
  completedAt: 0,
50
52
  createdAt: 0,
53
+ steps: [],
54
+ meta: {},
51
55
  },
52
56
  ],
53
57
  [
@@ -63,6 +67,8 @@ describe('serializeSnapshot', () => {
63
67
  progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
64
68
  createdAt: 0,
65
69
  cursor: { strategy: 'post', value: { offset: 5 } },
70
+ steps: [],
71
+ meta: {},
66
72
  },
67
73
  ],
68
74
  ]),
@@ -76,7 +82,7 @@ describe('serializeSnapshot', () => {
76
82
 
77
83
  describe('deserializeSnapshot', () => {
78
84
  test('restores resumable items into the paused phase', () => {
79
- const snap: PersistedSnapshot<Intents, Cursors, Purpose> = {
85
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = {
80
86
  version: 1,
81
87
  createdAt: 100,
82
88
  items: {
@@ -107,7 +113,7 @@ describe('deserializeSnapshot', () => {
107
113
  })
108
114
 
109
115
  test('drops items missing a cursor', () => {
110
- const snap: PersistedSnapshot<Intents, Cursors, Purpose> = {
116
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = {
111
117
  version: 1,
112
118
  createdAt: 100,
113
119
  items: {
@@ -129,13 +135,13 @@ describe('deserializeSnapshot', () => {
129
135
  })
130
136
 
131
137
  test('returns null without guards', () => {
132
- const snap: PersistedSnapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
138
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
133
139
  const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, { hasStrategy })
134
140
  expect(out).toBeNull()
135
141
  })
136
142
 
137
143
  test('accepts snapshots and ignores unrecognized version when not enforced', () => {
138
- const snap: PersistedSnapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
144
+ const snap: UploadPersistence.Snapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
139
145
  const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
140
146
  isPurpose,
141
147
  isIntent,
@@ -154,3 +160,32 @@ describe('MemoryAdapter', () => {
154
160
  expect(await MemoryAdapter.load('k')).toBeNull()
155
161
  })
156
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
+ })