@@ -75,12 +75,14 @@ function stringArrayToHexStripped (input) {
7575 return acc
7676}
7777
78- /**
79- * @typedef {Object } GetIPV6Result
80- * @property {boolean } error - Indicates if there was an error parsing the IPv6 address.
81- * @property {string } address - The parsed IPv6 address.
82- * @property {string } [zone] - The zone identifier, if present.
83- */
78+ /** @type {(value: string) => boolean } */
79+ const isHextet = RegExp . prototype . test . bind ( / ^ [ \d A - F a - f ] { 1 , 4 } $ / )
80+
81+ /** @type {(value: string) => boolean } */
82+ const isIPvFuture = RegExp . prototype . test . bind ( / ^ [ v V ] [ \d A - F a - f ] + \. [ A - Z a - z \d \- . _ ~ ! $ & ' ( ) * + , ; = : ] + $ / )
83+
84+ /** @type {(value: string) => boolean } */
85+ const isZoneCharacter = RegExp . prototype . test . bind ( / ^ [ A - Z a - z \d \- . _ ~ ] $ / )
8486
8587/**
8688 * @param {string } value
@@ -89,119 +91,154 @@ function stringArrayToHexStripped (input) {
8991const nonSimpleDomain = RegExp . prototype . test . bind ( / [ ^ ! " $ & ' ( ) * + , \- . ; = _ ` a - z { } ~ ] / u)
9092
9193/**
92- * @param {Array< string> } buffer
94+ * @param {string } zone
9395 * @returns {boolean }
9496 */
95- function consumeIsZone ( buffer ) {
96- buffer . length = 0
97+ function isZoneIdentifier ( zone ) {
98+ if ( zone . length === 0 ) return false
99+
100+ for ( let i = 0 ; i < zone . length ; i ++ ) {
101+ if ( isZoneCharacter ( zone [ i ] ) ) continue
102+ if ( zone [ i ] === '%' && i + 2 < zone . length && isHexPair ( zone . slice ( i + 1 , i + 3 ) ) ) {
103+ i += 2
104+ continue
105+ }
106+ return false
107+ }
108+
97109 return true
98110}
99111
100112/**
101- * @param {Array<string> } buffer
102- * @param {Array<string> } address
103- * @param {GetIPV6Result } output
104- * @returns {boolean }
113+ * Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
114+ * single zero hextet is left uncompressed. On ties the leftmost run wins.
115+ *
116+ * @param {string[] } hextets
117+ * @returns {string }
105118 */
106- function consumeHextets ( buffer , address , output ) {
107- if ( buffer . length ) {
108- const hex = stringArrayToHexStripped ( buffer )
109- if ( hex !== '' ) {
110- address . push ( hex )
119+ function compressIPv6ZeroRun ( hextets ) {
120+ let bestStart = - 1
121+ let bestLength = 0
122+ let runStart = - 1
123+ let runLength = 0
124+ for ( let i = 0 ; i < hextets . length ; i ++ ) {
125+ if ( hextets [ i ] === '0' ) {
126+ if ( runStart === - 1 ) runStart = i
127+ runLength ++
128+ if ( runLength > bestLength ) {
129+ bestLength = runLength
130+ bestStart = runStart
131+ }
111132 } else {
112- output . error = true
113- return false
133+ runStart = - 1
134+ runLength = 0
114135 }
115- buffer . length = 0
116136 }
117- return true
137+
138+ if ( bestLength < 2 ) return hextets . join ( ':' )
139+
140+ const head = hextets . slice ( 0 , bestStart ) . join ( ':' )
141+ const tail = hextets . slice ( bestStart + bestLength ) . join ( ':' )
142+ return head + '::' + tail
118143}
119144
120145/**
146+ * Validates an IPv6 address against the alternatives in RFC 3986 section
147+ * 3.2.2 and returns the same address with leading hextet zeroes removed.
148+ * An embedded IPv4 address counts as two hextets and is only valid at the end.
149+ *
121150 * @param {string } input
122- * @returns {GetIPV6Result }
151+ * @returns {string|undefined }
123152 */
124- function getIPV6 ( input ) {
125- let tokenCount = 0
126- const output = { error : false , address : '' , zone : '' }
127- /** @type {Array<string> } */
128- const address = [ ]
129- /** @type {Array<string> } */
130- const buffer = [ ]
131- let endipv6Encountered = false
132- let endIpv6 = false
133-
134- let consume = consumeHextets
153+ function normalizeIPv6Address ( input ) {
154+ const compression = input . indexOf ( '::' )
155+ if ( compression !== - 1 && input . indexOf ( '::' , compression + 1 ) !== - 1 ) return undefined
156+
157+ const left = compression === - 1 ? input . split ( ':' ) : input . slice ( 0 , compression ) . split ( ':' )
158+ const right = compression === - 1 ? [ ] : input . slice ( compression + 2 ) . split ( ':' )
159+ if ( compression !== - 1 ) {
160+ if ( left . length === 1 && left [ 0 ] === '' ) left . length = 0
161+ if ( right . length === 1 && right [ 0 ] === '' ) right . length = 0
162+ }
135163
136- for ( let i = 0 ; i < input . length ; i ++ ) {
137- const cursor = input [ i ]
138- if ( cursor === '[' || cursor === ']' ) { continue }
139- if ( cursor === ':' ) {
140- if ( endipv6Encountered === true ) {
141- endIpv6 = true
142- }
143- if ( ! consume ( buffer , address , output ) ) { break }
144- if ( ++ tokenCount > 7 ) {
145- // not valid
146- output . error = true
147- break
148- }
149- if ( i > 0 && input [ i - 1 ] === ':' ) {
150- endipv6Encountered = true
151- }
152- address . push ( ':' )
153- continue
154- } else if ( cursor === '%' ) {
155- if ( ! consume ( buffer , address , output ) ) { break }
156- // switch to zone detection
157- consume = consumeIsZone
158- } else {
159- buffer . push ( cursor )
164+ const parts = left . concat ( right )
165+ let hextetCount = 0
166+ for ( let i = 0 ; i < parts . length ; i ++ ) {
167+ const part = parts [ i ]
168+ if ( part === '' ) return undefined
169+
170+ if ( part . indexOf ( '.' ) !== - 1 ) {
171+ if ( i !== parts . length - 1 || ( compression !== - 1 && right . length === 0 ) || ! isIPv4 ( part ) ) return undefined
172+ hextetCount += 2
160173 continue
161174 }
175+
176+ if ( ! isHextet ( part ) ) return undefined
177+ parts [ i ] = parseInt ( part , 16 ) . toString ( 16 )
178+ hextetCount ++
162179 }
163- if ( buffer . length ) {
164- if ( consume === consumeIsZone ) {
165- output . zone = buffer . join ( '' )
166- } else if ( endIpv6 ) {
167- address . push ( buffer . join ( '' ) )
168- } else {
169- address . push ( stringArrayToHexStripped ( buffer ) )
170- }
180+
181+ if ( compression === - 1 ) {
182+ if ( hextetCount !== 8 ) return undefined
183+ return compressIPv6ZeroRun ( parts )
171184 }
172- output . address = address . join ( '' )
173- return output
185+ if ( hextetCount >= 8 ) return undefined
186+
187+ // expand "::" then re-compress the longest run for a canonical result
188+ const expanded = parts . slice ( 0 , left . length )
189+ for ( let i = hextetCount ; i < 8 ; i ++ ) expanded . push ( '0' )
190+ for ( let i = left . length ; i < parts . length ; i ++ ) expanded . push ( parts [ i ] )
191+ return compressIPv6ZeroRun ( expanded )
174192}
175193
176194/**
177195 * @typedef {Object } NormalizeIPv6Result
178196 * @property {string } host - The normalized host.
179197 * @property {string } [escapedHost] - The escaped host.
180198 * @property {boolean } isIPV6 - Indicates if the host is an IPv6 address.
199+ * @property {boolean } [isIPVFuture] - Indicates if the host is an IPvFuture literal.
200+ * @property {boolean } [error] - Indicates if a bracketed IP literal is malformed.
181201 */
182202
183203/**
204+ * Validates and normalizes a bracketed IP literal. Raw zone separators remain
205+ * accepted for backwards compatibility, while encoded separators and zone
206+ * contents follow RFC 6874.
207+ *
184208 * @param {string } host
185209 * @returns {NormalizeIPv6Result }
186210 */
187211function normalizeIPv6 ( host ) {
188- if ( findToken ( host , ':' ) < 2 ) { return { host, isIPV6 : false } }
189- const ipv6 = getIPV6 ( host )
190-
191- if ( ! ipv6 . error ) {
192- let newHost = ipv6 . address
193- let escapedHost = ipv6 . address
194- if ( ipv6 . zone ) {
195- // RFC 6874 encodes the zone separator as "%25" in a URI. Accept both
196- // component forms used by this API ("%zone" and "%25zone") while
197- // consuming at most that one separator escape.
198- const zone = ipv6 . zone . startsWith ( '25' ) ? ipv6 . zone . slice ( 2 ) : ipv6 . zone
199- newHost += '%' + zone
200- escapedHost += '%25' + zone
201- }
202- return { host : newHost , isIPV6 : true , escapedHost }
203- } else {
204- return { host, isIPV6 : false }
212+ const bracketed = host [ 0 ] === '[' && host [ host . length - 1 ] === ']'
213+ const hasBracket = host [ 0 ] === '[' || host [ host . length - 1 ] === ']'
214+ if ( hasBracket && ! bracketed ) return { host, isIPV6 : false , error : true }
215+
216+ let input = bracketed ? host . slice ( 1 , - 1 ) : host
217+ if ( bracketed && isIPvFuture ( input ) ) {
218+ input = input . toLowerCase ( )
219+ return { host : `[${ input } ]` , escapedHost : input , isIPV6 : false , isIPVFuture : true }
220+ }
221+
222+ if ( findToken ( input , ':' ) < 2 ) {
223+ return { host, isIPV6 : false , error : bracketed }
224+ }
225+
226+ let zoneIdentifier = ''
227+ const zoneSeparator = input . indexOf ( '%' )
228+ if ( zoneSeparator !== - 1 ) {
229+ const separatorLength = input . slice ( zoneSeparator , zoneSeparator + 3 ) . toLowerCase ( ) === '%25' ? 3 : 1
230+ zoneIdentifier = input . slice ( zoneSeparator + separatorLength )
231+ if ( ! isZoneIdentifier ( zoneIdentifier ) ) return { host, isIPV6 : false , error : true }
232+ input = input . slice ( 0 , zoneSeparator )
233+ }
234+
235+ const address = normalizeIPv6Address ( input )
236+ if ( address === undefined ) return { host, isIPV6 : false , error : true }
237+
238+ return {
239+ host : address + ( zoneIdentifier ? '%' + zoneIdentifier : '' ) ,
240+ escapedHost : address + ( zoneIdentifier ? '%25' + zoneIdentifier : '' ) ,
241+ isIPV6 : true
205242 }
206243}
207244
@@ -657,12 +694,16 @@ function recomposeAuthority (component) {
657694 }
658695
659696 if ( component . host !== undefined ) {
660- // Decode only unreserved bytes, once. In particular, keep %25 encoded so
661- // it cannot become the introducer for a second escape during recomposition.
662- let host = normalizePercentEncoding ( component . host , true )
697+ let host = component . host
663698 if ( ! isIPv4 ( host ) ) {
664- const ipV6res = normalizeIPv6 ( host )
665- if ( ipV6res . isIPV6 === true ) {
699+ let ipV6res = normalizeIPv6 ( host )
700+ if ( ipV6res . isIPV6 !== true && ipV6res . isIPVFuture !== true ) {
701+ // Decode only unreserved bytes, once. In particular, keep %25 encoded
702+ // so it cannot introduce a second escape during recomposition.
703+ host = normalizePercentEncoding ( host , true )
704+ ipV6res = normalizeIPv6 ( host )
705+ }
706+ if ( ipV6res . isIPV6 === true || ipV6res . isIPVFuture === true ) {
666707 host = `[${ ipV6res . escapedHost } ]`
667708 } else {
668709 host = reescapeHostDelimiters ( host , false )
0 commit comments