@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.
- package/README.md +170 -193
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -51
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +49 -34
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +7 -3
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +7 -24
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -1,56 +1,2 @@
|
|
|
1
|
-
import {
|
|
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 };
|
package/dist/strategies/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
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.
|
|
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": "^
|
|
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 })
|
|
@@ -21,10 +21,20 @@ describe('isRecord', () => {
|
|
|
21
21
|
expect(isRecord(true)).toBe(false)
|
|
22
22
|
})
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 {
|
|
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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
})
|