@@ -40,6 +40,44 @@ describe('the premise these helpers exist for', () => {
4040 expect ( new URL ( 'https://x/v1/a/b/..' ) . pathname ) . toBe ( '/v1/a/' )
4141 expect ( new URL ( 'https://x/v1/a/b/%2e%2e' ) . pathname ) . toBe ( '/v1/a/' )
4242 } )
43+
44+ /**
45+ * The removable set is the ELEVEN spellings the URL Standard defines, not
46+ * just the two literal ones. The helpers below match only the literal
47+ * spellings, which is sufficient solely because `encodeURIComponent` escapes
48+ * `%` and so can never emit a `%2e` form. Both halves are asserted here,
49+ * because the second is what makes the first safe.
50+ */
51+ it . concurrent . each ( [
52+ [ '.' , '/v1/a/' ] ,
53+ [ '%2e' , '/v1/a/' ] ,
54+ [ '%2E' , '/v1/a/' ] ,
55+ [ '..' , '/v1/' ] ,
56+ [ '.%2e' , '/v1/' ] ,
57+ [ '.%2E' , '/v1/' ] ,
58+ [ '%2e.' , '/v1/' ] ,
59+ [ '%2E.' , '/v1/' ] ,
60+ [ '%2e%2e' , '/v1/' ] ,
61+ [ '%2E%2E' , '/v1/' ] ,
62+ [ '%2e%2E' , '/v1/' ] ,
63+ ] as const ) (
64+ 'the parser also removes the encoded dot-segment spelling %j (=> %j)' ,
65+ ( spelling , expected ) => {
66+ expect ( new URL ( `https://x/v1/a/${ spelling } ` ) . pathname ) . toBe ( expected )
67+ }
68+ )
69+
70+ it . concurrent . each ( [ '...' , '%2e%2e%2e' , '%252e' , 'a%2e' ] ) (
71+ 'the parser does NOT remove %j' ,
72+ ( spelling ) => {
73+ expect ( new URL ( `https://x/v1/a/${ spelling } ` ) . pathname . startsWith ( '/v1/a/' ) ) . toBe ( true )
74+ }
75+ )
76+
77+ it . concurrent ( 'encodeURIComponent escapes % so no helper can emit a %2e spelling' , ( ) => {
78+ expect ( encodeURIComponent ( '%2e%2e' ) ) . toBe ( '%252e%252e' )
79+ expect ( new URL ( 'https://x/v1/a/%252e%252e' ) . pathname ) . toBe ( '/v1/a/%252e%252e' )
80+ } )
4381} )
4482
4583describe ( 'safeUrlPathSegment' , ( ) => {
@@ -212,8 +250,10 @@ describe('safeUrlPath', () => {
212250
213251 /**
214252 * Dropping the per-segment trim must not re-open traversal. The WHATWG
215- * parser only removes a segment that is *exactly* `.` or `..`; a padded one
216- * encodes to inert text and stays put.
253+ * parser removes a segment only when the whole segment spells a dot segment
254+ * — literally or percent-encoded — and a padded one spells neither, so it
255+ * encodes to inert text and stays put. The percent-encoded spellings are
256+ * unreachable from here because `encodeURIComponent` escapes `%` itself.
217257 */
218258 it . concurrent . each ( [ 'a/ .. /b' , 'a/ ../b' , 'a/.. /b' , 'a/ . /b' ] as const ) (
219259 'keeps the padded dot segment %j inert instead of popping a segment' ,
@@ -346,8 +386,10 @@ describe('safeUrlPath options', () => {
346386 } )
347387
348388 /**
349- * Neither option touches the dot-segment check, which is an EXACT match on
350- * the segment — the only spelling the WHATWG parser actually removes.
389+ * Neither option touches the dot-segment check. An exact match on the raw
390+ * segment suffices because every segment then goes through
391+ * `encodeURIComponent`, which escapes `%` and so cannot emit the
392+ * percent-encoded dot-segment spellings the parser also removes.
351393 */
352394 it . concurrent . each ( [ 'a//../b' , '/..' , '../' , '..' , '.' , 'a/../b' , '/a/./b' , '..//..' ] as const ) (
353395 'still rejects the dot segment in %j under storage options' ,
@@ -440,3 +482,144 @@ describe('safeOpaqueUrlSegment', () => {
440482 expect ( safeOpaqueUrlSegment ( 'foo/bar' , 'objectID' ) ) . toBe ( 'foo%2Fbar' )
441483 } )
442484} )
485+
486+ /**
487+ * The coercion is deliberately narrow. It exists so an id the caller genuinely
488+ * supplied as a JSON number is not reported as missing, and it must not be a
489+ * general `String(value)` — that turns a wrong-shaped value into a plausible
490+ * but wrong path segment instead of a clean, named error.
491+ */
492+ describe ( 'coercion boundary' , ( ) => {
493+ const HELPERS = [
494+ [ 'safeUrlPathSegment' , safeUrlPathSegment ] ,
495+ [ 'safeUrlPath' , safeUrlPath ] ,
496+ [ 'safeOpaqueUrlSegment' , safeOpaqueUrlSegment ] ,
497+ ] as const
498+
499+ it . concurrent . each ( [
500+ [ 'string' , 'abc' , 'abc' ] ,
501+ [ 'zero' , 0 , '0' ] ,
502+ [ 'negative' , - 7 , '-7' ] ,
503+ [ 'decimal' , 1.5 , '1.5' ] ,
504+ [ 'bigint' , 42n , '42' ] ,
505+ [ 'large safe integer' , 9007199254740991 , '9007199254740991' ] ,
506+ ] as const ) ( 'accepts the %s as the expected string' , ( _label , value , expected ) => {
507+ for ( const [ name , helper ] of HELPERS ) {
508+ expect ( `${ name } :${ helper ( value as never , 'id' ) } ` ) . toBe ( `${ name } :${ expected } ` )
509+ }
510+ } )
511+
512+ it . concurrent . each ( [
513+ [ 'plain object' , { } ] ,
514+ [ 'populated object' , { a : 1 } ] ,
515+ [ 'Map' , new Map ( ) ] ,
516+ [ 'null-prototype object' , Object . create ( null ) ] ,
517+ [ 'true' , true ] ,
518+ [ 'false' , false ] ,
519+ [ 'NaN' , Number . NaN ] ,
520+ [ 'Infinity' , Number . POSITIVE_INFINITY ] ,
521+ [ '-Infinity' , Number . NEGATIVE_INFINITY ] ,
522+ [ 'array' , [ 1 , 2 ] ] ,
523+ [ 'Date' , new Date ( 0 ) ] ,
524+ [ 'symbol' , Symbol ( 's' ) ] ,
525+ [ 'exponential number' , 1e21 ] ,
526+ [ 'unsafe integer' , Number . MAX_SAFE_INTEGER + 2 ] ,
527+ [ 'snowflake-sized id parsed as a number' , Number ( '1234567890123456789' ) ] ,
528+ [ 'function' , ( ) => 'x' ] ,
529+ ] as const ) ( 'rejects the %s with an error naming the param' , ( _label , value ) => {
530+ for ( const [ name , helper ] of HELPERS ) {
531+ let thrown : unknown = null
532+ try {
533+ helper ( value as never , 'objectId' )
534+ } catch ( error ) {
535+ thrown = error
536+ }
537+ expect ( `${ name } :${ thrown instanceof Error } ` ) . toBe ( `${ name } :true` )
538+ expect ( `${ name } :${ ( thrown as Error ) . message } ` ) . toContain ( 'objectId' )
539+ expect ( ( thrown as Error ) . message ) . not . toContain ( '[object' )
540+ expect ( ( thrown as Error ) . message ) . not . toMatch ( / N o d e f a u l t v a l u e / )
541+ }
542+ } )
543+
544+ /**
545+ * `null` and `undefined` keep reporting *"is required"* — the distinction
546+ * between "you sent nothing" and "you sent the wrong kind of thing" is what
547+ * makes the error actionable.
548+ */
549+ it . concurrent . each ( [
550+ [ 'null' , null ] ,
551+ [ 'undefined' , undefined ] ,
552+ ] as const ) ( 'keeps the required error for %s rather than the invalid-value one' , ( _l , value ) => {
553+ for ( const [ name , helper ] of HELPERS ) {
554+ expect ( ( ) => helper ( value as never , 'objectId' ) ) . toThrow ( / o b j e c t I d i s r e q u i r e d / )
555+ expect ( `${ name } ` ) . toBe ( name )
556+ }
557+ } )
558+
559+ it . concurrent ( 'never lets a rejected value reach the built path' , ( ) => {
560+ for ( const value of [ { } , true , Number . NaN , [ 1 , 2 ] , 1e21 , new Date ( 0 ) ] ) {
561+ let built : string | null = null
562+ try {
563+ built = `${ ORIGIN } /v1/${ safeUrlPathSegment ( value as never , 'id' ) } `
564+ } catch {
565+ continue
566+ }
567+ expect ( built ) . toBeNull ( )
568+ }
569+ } )
570+ } )
571+
572+ /**
573+ * The 44 live call sites (Vercel x43, Daytona x1) pass provider ids and
574+ * hostnames as strings, occasionally a numeric id. Their output must be
575+ * byte-identical across this change.
576+ */
577+ describe ( 'live call-site values' , ( ) => {
578+ it . concurrent . each ( [
579+ 'prj_2rXy9Qh0lE8vJmKpZ4aB1cD' ,
580+ 'dpl_9fJk2LmN4pQr7sT1uV3wX5yZ' ,
581+ 'team_abcDEF123' ,
582+ 'my-app.vercel.app' ,
583+ 'example.com' ,
584+ 'sub.domain.example.co.uk' ,
585+ 'rec_1a2b3c' ,
586+ 'ecfg_xyz' ,
587+ '3f1c9a1e-6f27-4b2e-9b0f-2a1d4e5c6b7a' ,
588+ ] ) ( 'passes %j through unchanged' , ( value ) => {
589+ expect ( safeUrlPathSegment ( value , 'id' ) ) . toBe ( value )
590+ } )
591+
592+ it . concurrent ( 'still stringifies a numeric id' , ( ) => {
593+ expect ( safeUrlPathSegment ( 2487956 , 'woeid' ) ) . toBe ( '2487956' )
594+ expect ( safeUrlPathSegment ( 0 , 'folderId' ) ) . toBe ( '0' )
595+ } )
596+ } )
597+
598+ /**
599+ * The rejection set is NOT option-invariant: `' .. '` trims to the exact dot
600+ * segment under the default and is therefore rejected, while
601+ * `preserveOuterWhitespace` keeps it as inert `%20..%20` text that the WHATWG
602+ * parser leaves in place.
603+ */
604+ describe ( 'preserveOuterWhitespace changes the rejection set' , ( ) => {
605+ it . concurrent ( 'rejects " .. " by default but accepts it as inert text with the option' , ( ) => {
606+ expect ( ( ) => safeUrlPath ( ' .. ' , 'path' ) ) . toThrow ( / p a t h / )
607+ expect ( safeUrlPath ( ' .. ' , 'path' , { preserveOuterWhitespace : true } ) ) . toBe ( '%20..%20' )
608+
609+ const url = new URL (
610+ `${ ORIGIN } /storage/v1/object/bkt/${ safeUrlPath ( ' .. ' , 'path' , { preserveOuterWhitespace : true } ) } `
611+ )
612+ expect ( url . pathname ) . toBe ( '/storage/v1/object/bkt/%20..%20' )
613+ expect ( url . pathname ) . not . toContain ( '/../' )
614+ } )
615+
616+ it . concurrent ( 'rejects " . " by default but keeps it inert with the option' , ( ) => {
617+ expect ( ( ) => safeUrlPath ( ' . ' , 'path' ) ) . toThrow ( / p a t h / )
618+ expect ( safeUrlPath ( ' . ' , 'path' , { preserveOuterWhitespace : true } ) ) . toBe ( '%20.%20' )
619+ } )
620+
621+ it . concurrent ( 'still rejects an untrimmed exact dot segment under the option' , ( ) => {
622+ expect ( ( ) => safeUrlPath ( '..' , 'path' , { preserveOuterWhitespace : true } ) ) . toThrow ( / p a t h / )
623+ expect ( ( ) => safeUrlPath ( 'a/../b' , 'path' , { preserveOuterWhitespace : true } ) ) . toThrow ( / p a t h / )
624+ } )
625+ } )
0 commit comments