Sign In

fast-uri

Package Overview
Dependencies
Maintainers
10
Versions
35
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

fast-uri - npm Package Compare versions

Comparing version
4.1.2
to
4.1.3
+105
test/component-safe-serialization.test.js
'use strict'
const test = require('tape')
const fastURI = require('..')
test('userinfo serialization cannot terminate the authority', (t) => {
const uri = fastURI.serialize({
scheme: 'http',
userinfo: 'attacker.example/',
host: 'trusted.example',
path: '/'
})
t.equal(uri, 'http://attacker.example%2F@trusted.example/', 'slash is encoded as userinfo data')
const reparsed = fastURI.parse(uri)
t.equal(reparsed.host, 'trusted.example', 'reparsing retains the supplied host')
t.equal(reparsed.userinfo, 'attacker.example%2F', 'encoded slash remains in userinfo')
t.equal(
fastURI.serialize({ userinfo: 'user@/?:#[]\\', host: 'example.test' }),
'//user%40%2F%3F:%23%5B%5D%5C@example.test',
'all userinfo component delimiters are encoded as data'
)
t.end()
})
test('query and fragment serialization cannot inject component delimiters', (t) => {
const uri = fastURI.serialize({
scheme: 'x',
path: '/resource',
query: 'key=value#still-query',
fragment: 'first#still-fragment'
})
t.equal(
uri,
'x:/resource?key=value%23still-query#first%23still-fragment',
'raw hashes are encoded within their supplied components'
)
const reparsed = fastURI.parse(uri)
t.equal(reparsed.query, 'key=value%23still-query', 'hash remains query data')
t.equal(reparsed.fragment, 'first%23still-fragment', 'hash remains fragment data')
t.end()
})
test('component serializers preserve each RFC 3986 literal character set', (t) => {
const unreservedAndSubDelims = "AZaz09-._~!$&'()*+,;="
const userinfo = unreservedAndSubDelims + ':'
const queryOrFragment = unreservedAndSubDelims + ':@/?'
t.equal(
fastURI.serialize({ userinfo, host: 'example.test' }),
'//' + userinfo + '@example.test',
'userinfo literals are preserved'
)
t.equal(
fastURI.serialize({ query: queryOrFragment }),
'?' + queryOrFragment,
'query literals are preserved'
)
t.equal(
fastURI.serialize({ fragment: queryOrFragment }),
'#' + queryOrFragment,
'fragment literals are preserved'
)
t.end()
})
test('component serializers preserve escapes and encode Unicode as UTF-8', (t) => {
t.equal(
fastURI.serialize({ userinfo: 'café/%2f%GG', host: 'example.test' }),
'//caf%C3%A9%2F%2F%25GG@example.test',
'userinfo preserves valid escapes, uppercases hex, and escapes malformed percent data'
)
t.equal(
fastURI.serialize({ query: '日本/%2f#%GG' }),
'?%E6%97%A5%E6%9C%AC/%2F%23%25GG',
'query uses UTF-8 and does not double encode valid escapes'
)
t.equal(
fastURI.serialize({ fragment: '😀/%3f#%GG' }),
'#%F0%9F%98%80/%3F%23%25GG',
'fragment uses UTF-8 and does not double encode valid escapes'
)
t.equal(
fastURI.serialize({ query: 'Kſ' }),
'?%E2%84%AA%C5%BF',
'Unicode characters that case-fold to ASCII are still UTF-8 encoded'
)
t.end()
})
test('parsed URI serialization remains stable', (t) => {
const input = 'x://user:pass@example.test/a%2Fb?x=a/b?c&y=%23#frag/a?b%23'
const serialized = fastURI.serialize(fastURI.parse(input))
t.equal(serialized, input, 'an already normalized parsed URI round-trips unchanged')
t.equal(
fastURI.serialize(fastURI.parse(serialized)),
serialized,
'repeated parse and serialize cycles are stable'
)
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
test('IPv6 hosts normalize to the RFC 5952 canonical form', (t) => {
const cases = [
['http://[0:0:0:0:0:0:0:1]/', 'http://[::1]/'],
['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/'],
['http://[2001:0db8:0000:0000:0000:0000:0000:0001]/', 'http://[2001:db8::1]/'],
['http://[2001:0:0:0:0:0:0:1]/', 'http://[2001::1]/'],
['http://[fe80:0:0:0:0:0:0:1]/', 'http://[fe80::1]/'],
['http://[1:0:0:0:2:0:0:3]/', 'http://[1::2:0:0:3]/']
]
for (const [uri, normalized] of cases) {
t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes to ${normalized}`)
}
t.end()
})
test('IPv6 equal() matches the same address across compressed and expanded forms', (t) => {
const pairs = [
['http://[::1]/', 'http://[0:0:0:0:0:0:0:1]/'],
['http://[::]/', 'http://[0:0:0:0:0:0:0:0]/'],
['http://[2001:db8::1]/', 'http://[2001:0db8:0000:0000:0000:0000:0000:0001]/'],
['http://[1::2:0:0:3]/', 'http://[1:0:0:0:2:0:0:3]/']
]
for (const [a, b] of pairs) {
t.equal(fastURI.equal(a, b), true, `${a} equals ${b}`)
}
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
const HOST_ERROR = 'URI host is malformed.'
const malformedLiterals = [
'::not-valid',
'fc00::not-hex',
'fe80::not-hex',
'1:2:3',
'1:2:3:4:5:6:7',
'1:2:3:4:5:6:7:8:9',
'1::2::3',
'1:::2',
':::1',
'12345::',
'1:2:3:4:5:6:7::8',
'::ffff:192.0.2.999',
'::ffff:192.0.2',
'::ffff:192.168.001.1',
'::192.0.2.1:1',
'1:2:3:4:5:192.0.2.1::',
'v.foo',
'v1.',
'v1.foo%25bar',
'v1.K',
'fe80::1%25',
'fe80::1%25eth 0',
'fe80::1%25eth%ZZ',
'fe80::1%25K',
'not-an-ip'
]
test('malformed bracketed IP literals fail without being rewritten', (t) => {
for (const literal of malformedLiterals) {
const uri = `http://[${literal}]/private`
const parsed = fastURI.parse(uri)
t.equal(parsed.error, HOST_ERROR, `parse rejects ${literal}`)
t.equal(parsed.host, `[${literal.toLowerCase()}]`, `parse does not truncate ${literal}`)
t.equal(fastURI.normalize(uri), uri, `normalize preserves ${literal}`)
t.equal(fastURI.equal(uri, uri), false, `equal rejects ${literal}`)
}
t.end()
})
test('resolve throws for malformed bracketed IP literals', (t) => {
for (const literal of malformedLiterals) {
const uri = `http://[${literal}]/private`
t.throws(
() => fastURI.resolve(uri, 'child'),
/URI host is malformed\./,
`rejects malformed base ${literal}`
)
t.throws(
() => fastURI.resolve('http://example.com/', uri),
/URI host is malformed\./,
`rejects malformed relative input ${literal}`
)
}
t.end()
})
test('valid IPv6, IPvFuture, embedded IPv4, and zone forms normalize safely', (t) => {
const cases = [
['http://[::]/', 'http://[::]/', '::'],
['http://[::1]/', 'http://[::1]/', '::1'],
['http://[1::]/', 'http://[1::]/', '1::'],
['http://[2001:0DB8::0001]/', 'http://[2001:db8::1]/', '2001:db8::1'],
['http://[0:0:0:0:0:0:0:0]/', 'http://[::]/', '::'],
['http://[::ffff:192.0.2.1]/', 'http://[::ffff:192.0.2.1]/', '::ffff:192.0.2.1'],
['http://[1:2:3:4:5:6:192.0.2.1]/', 'http://[1:2:3:4:5:6:192.0.2.1]/', '1:2:3:4:5:6:192.0.2.1'],
['http://[fe80::A%25EN1]/', 'http://[fe80::a%25EN1]/', 'fe80::a%EN1'],
['http://[fe80::a%en1]/', 'http://[fe80::a%25en1]/', 'fe80::a%en1'],
['http://[fe80::a%25eth%2D0]/', 'http://[fe80::a%25eth%2D0]/', 'fe80::a%eth%2D0'],
['http://[v1.example]/', 'http://[v1.example]/', '[v1.example]'],
['http://[vF.A:b]/', 'http://[vf.a:b]/', '[vf.a:b]']
]
for (const [uri, normalized, host] of cases) {
const parsed = fastURI.parse(uri)
t.equal(parsed.error, undefined, `${uri} parses without error`)
t.equal(parsed.host, host, `${uri} has the expected host`)
t.equal(fastURI.normalize(uri), normalized, `${uri} normalizes safely`)
}
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
const MALFORMED_PERCENT_ERROR = 'URI contains malformed percent-encoding.'
const malformedComponents = [
['path', 'x://example.test/a%'],
['path with one trailing hex digit', 'x://example.test/a%2'],
['path with non-hex digits', 'x://example.test/a%GG'],
['host', 'x://exa%mple.test/path'],
['userinfo', 'x://us%er@example.test/path'],
['query', 'x://example.test/path?q=%G0'],
['fragment', 'x://example.test/path#frag%1']
]
test('parse reports malformed percent syntax in generic URI components', (t) => {
for (const [component, uri] of malformedComponents) {
t.equal(fastURI.parse(uri).error, MALFORMED_PERCENT_ERROR, component)
}
t.end()
})
test('normalize preserves malformed percent input unchanged', (t) => {
for (const [component, uri] of malformedComponents) {
t.equal(fastURI.normalize(uri), uri, component)
}
t.end()
})
test('equal does not equate malformed input with repaired valid input', (t) => {
const cases = [
['x://example.test/a%', 'x://example.test/a%25'],
['x://exa%mple.test/path', 'x://exa%25mple.test/path'],
['x://us%er@example.test/path', 'x://us%25er@example.test/path'],
['x://example.test/path?q=%', 'x://example.test/path?q=%25'],
['x://example.test/path#%', 'x://example.test/path#%25']
]
for (const [malformed, repaired] of cases) {
t.equal(fastURI.equal(malformed, repaired, {}), false, malformed)
}
t.end()
})
test('resolve rejects malformed percent syntax in either input', (t) => {
t.throws(
() => fastURI.resolve('x://example.test/base%', 'child'),
/URI contains malformed percent-encoding\./,
'malformed base'
)
t.throws(
() => fastURI.resolve('x://example.test/base', 'child%'),
/URI contains malformed percent-encoding\./,
'malformed relative reference'
)
t.end()
})
test('valid percent octets remain valid without UTF-8 validation', (t) => {
const uri = '/%ff?q=%80#%fe'
const parsed = fastURI.parse(uri)
t.equal(parsed.error, undefined, 'non-UTF-8 octets are valid percent syntax')
t.equal(parsed.path, '/%FF', 'path octet is preserved and hex is uppercased')
t.equal(parsed.query, 'q=%80', 'query octet is preserved')
t.equal(parsed.fragment, '%FE', 'fragment octet is preserved and hex is uppercased')
t.equal(fastURI.normalize(uri), '/%FF?q=%80#%FE', 'normalization preserves the octets')
const allGenericComponents = fastURI.parse('x://u%2f@exa%2fmple.test/%2f?q=%2f#%2f')
t.equal(allGenericComponents.error, undefined, 'valid escapes are accepted in every generic component')
const ipv6Zone = fastURI.parse('//[2001:db8::7%en0]')
t.equal(ipv6Zone.error, undefined, 'historically accepted raw IPv6 zone separator is unchanged')
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
const malformedURNs = [
'urn:',
'URN:',
'urn:foo',
'urn::foo',
'urn:foo:',
'urn:%66oo:bar'
]
test('parse reports malformed ordinary URNs', (t) => {
for (const uri of malformedURNs) {
t.match(fastURI.parse(uri).error, /^URN can not be parsed\.?$/, uri)
}
t.end()
})
test('normalize preserves malformed ordinary URNs without throwing', (t) => {
for (const uri of malformedURNs) {
t.doesNotThrow(() => fastURI.normalize(uri), `${uri} does not throw`)
t.equal(fastURI.normalize(uri), uri, `${uri} is preserved`)
}
t.equal(
fastURI.normalize('urn:foo', { reference: 'relative' }),
'urn:foo',
'an earlier parse error does not hide the missing URN nid'
)
t.end()
})
test('equal returns false for malformed ordinary URNs', (t) => {
for (const uri of malformedURNs) {
t.equal(fastURI.equal(uri, uri, {}), false, `${uri} is not equal to itself as malformed input`)
t.equal(fastURI.equal(uri, 'urn:foo:bar', {}), false, `${uri} is not equal to a valid URN`)
}
t.end()
})
test('resolve handles malformed ordinary URNs without throwing', (t) => {
for (const uri of malformedURNs) {
t.doesNotThrow(() => fastURI.resolve('uri://base/', uri), `${uri} does not throw as a relative reference`)
t.doesNotThrow(() => fastURI.resolve(uri, ''), `${uri} does not throw as a base URI`)
}
// resolve preserves the malformed scheme-specific input rather than surfacing
// an uncaught 'URN without nid cannot be serialized' error (matches upstream uri-js)
t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'malformed relative URN is preserved')
t.equal(fastURI.resolve('URN:', ''), 'urn:', 'scheme case is normalized')
t.equal(fastURI.resolve('uri://base/', 'urn:%66oo:bar'), 'urn:foo:bar', 'percent-encoding is decoded')
t.end()
})
test('valid URNs retain their existing behavior', (t) => {
t.equal(fastURI.normalize('URN:FOO:a123,456'), 'urn:foo:a123,456')
t.equal(fastURI.equal('urn:foo:a123,456', 'URN:FOO:a123,456', {}), true)
t.equal(fastURI.resolve('uri://base/', 'urn:'), 'urn:', 'default resolve behavior is unchanged')
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
const PATH_RESERVED = "!$&'()*+,;=:@/"
function percentEncode (character, lowerCase) {
const hex = character.charCodeAt(0).toString(16).padStart(2, '0')
return '%' + (lowerCase ? hex : hex.toUpperCase())
}
test('normalize preserves literal and escaped reserved path characters', (t) => {
t.equal(
fastURI.normalize('http://example.com/a;b'),
'http://example.com/a;b',
'literal semicolon remains literal'
)
t.equal(
fastURI.normalize('http://example.com/a%3ab'),
'http://example.com/a%3Ab',
'escaped colon remains escaped and its hex is uppercased'
)
for (const character of PATH_RESERVED) {
const literal = `http://example.com/a${character}b`
const escaped = `http://example.com/a${percentEncode(character, true)}b`
const normalizedEscape = `http://example.com/a${percentEncode(character, false)}b`
t.equal(fastURI.normalize(literal), literal, `preserves literal ${character}`)
t.equal(fastURI.normalize(escaped), normalizedEscape, `preserves escaped ${character}`)
}
t.equal(
fastURI.normalize('http://example.com/a/./café'),
'http://example.com/a/caf%C3%A9',
'removes real dot segments and UTF-8 encodes raw non-ASCII'
)
t.equal(
fastURI.normalize('http://example.com/a/%2e%2e/b'),
'http://example.com/a/%2E%2E/b',
'preserves escaped dots as path data'
)
t.equal(
fastURI.normalize('http://example.com/Kſ'),
'http://example.com/%E2%84%AA%C5%BF',
'UTF-8 encodes Unicode characters that case-fold to ASCII'
)
t.end()
})
test('serialize uses the RFC 3986 path character set without opening escapes', (t) => {
const rawPath = `/a${PATH_RESERVED}b`
t.equal(
fastURI.serialize({ scheme: 'http', host: 'example.com', path: rawPath }),
`http://example.com${rawPath}`,
'keeps all legal raw path characters'
)
const escapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, true)).join('')
const normalizedEscapedPath = Array.from(PATH_RESERVED, (character) => percentEncode(character, false)).join('')
t.equal(
fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/' + escapedPath }),
'http://example.com/' + normalizedEscapedPath,
'uppercases valid escapes without decoding reserved characters'
)
t.equal(
fastURI.serialize({ scheme: 'http', host: 'example.com', path: '/a?b#c[d]' }),
'http://example.com/a%3Fb%23c%5Bd%5D',
'encodes characters that would leave the path component'
)
t.equal(
fastURI.serialize({ path: 'a:b/c:d' }),
'a%3Ab/c:d',
'only escapes a colon where path-noscheme requires it'
)
t.equal(
fastURI.serialize({ path: './a:b/c:d' }),
'a%3Ab/c:d',
'escapes a first-segment colon exposed by dot-segment removal'
)
t.equal(
fastURI.normalize('./a:b'),
'a%3Ab',
'normalization keeps a relative path from becoming a scheme'
)
t.equal(
fastURI.serialize({ path: '/Kſ' }),
'/%E2%84%AA%C5%BF',
'UTF-8 encodes Unicode characters that case-fold to ASCII'
)
t.end()
})
test('equal distinguishes escaped reserved path data from literal syntax', (t) => {
for (const character of PATH_RESERVED) {
const literal = `http://example.com/a${character}b`
const escaped = `http://example.com/a${percentEncode(character, false)}b`
t.equal(fastURI.equal(literal, escaped, {}), false, `distinguishes literal and escaped ${character}`)
}
t.equal(
fastURI.equal('./a:b', 'a:b', {}),
false,
'does not equate a relative path with an absolute URI after removing dot segments'
)
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
const malformedSchemes = [
'%2f%2fevil.example:/pwn',
'%u002f%u002fevil.example:/pwn',
'%0d%0aSet-Cookie:%20sid=attacker:/p',
'foo%3Abar:value',
'foo%2Fbar:value',
'1http://example.com/',
'foo_bar:value',
'éxample:value',
'Kttp://example.com/',
'ſcheme:value'
]
test('parse validates the decoded scheme against RFC 3986', (t) => {
const validSchemes = [
['a:value', 'a'],
['HTTP://example.com/', 'http'],
['a1+.-:value', 'a1+.-'],
['%4Aavascript:alert(1)', 'javascript'],
['foo%2Bbar:value', 'foo+bar'],
['%u006Aavascript:1', 'javascript'],
['ht%74ps://example.com/', 'https']
]
for (const [uri, scheme] of validSchemes) {
const parsed = fastURI.parse(uri)
t.equal(parsed.error, undefined, uri)
t.equal(parsed.scheme, scheme, uri + ' scheme')
}
for (const uri of malformedSchemes) {
const parsed = fastURI.parse(uri)
t.equal(parsed.error, MALFORMED_SCHEME_ERROR, uri)
}
t.end()
})
test('decoded schemes select their scheme handlers', (t) => {
t.equal(
fastURI.normalize('ht%74ps://example.com:443'),
'https://example.com/',
'HTTP normalization runs after decoding the scheme'
)
const mailto = fastURI.parse('ma%69lto:user@example.org')
t.deepEqual(mailto.to, ['user@example.org'], 'mailto parsing runs after decoding the scheme')
t.end()
})
test('normalize preserves schemes that decode to invalid identifiers', (t) => {
for (const uri of malformedSchemes) {
t.equal(fastURI.normalize(uri), uri, uri)
}
t.end()
})
test('scheme normalization cannot introduce authority or control delimiters', (t) => {
const authority = '%2f%2fevil.example:/pwn'
const crlf = '%0d%0aSet-Cookie:%20sid=attacker:/p'
t.equal(fastURI.parse(authority).host, undefined, 'original input has no authority')
t.equal(fastURI.normalize(authority), authority, 'normalization does not create an authority')
t.equal(fastURI.normalize(crlf), crlf, 'normalization does not emit raw CRLF')
t.equal(fastURI.normalize(crlf).includes('\r\n'), false, 'normalized output contains no raw CRLF')
t.end()
})
test('equal returns false for malformed decoded schemes', (t) => {
for (const uri of malformedSchemes) {
t.equal(fastURI.equal(uri, uri, {}), false, uri)
}
t.end()
})
test('resolve rejects malformed decoded schemes in either input', (t) => {
t.throws(
() => fastURI.resolve('%2f%2fevil.example:/base', 'child'),
/URI scheme is malformed\./,
'malformed base'
)
t.throws(
() => fastURI.resolve('https://allowed.example/app/', '%2f%2fevil.example:/pwn'),
/URI scheme is malformed\./,
'malformed relative reference'
)
t.end()
})
test('serialize validates decoded component schemes', (t) => {
t.equal(
fastURI.serialize({ scheme: 'foo%2Bbar', path: 'value' }),
'foo+bar:value',
'valid decoded scheme is serialized'
)
t.throws(
() => fastURI.serialize({ scheme: '//evil.example', path: '/pwn' }),
/URI scheme is malformed\./,
'raw invalid scheme'
)
t.throws(
() => fastURI.serialize({ scheme: '%2f%2fevil.example', path: '/pwn' }),
/URI scheme is malformed\./,
'encoded invalid scheme'
)
t.equal(
fastURI.equal(
{ scheme: '%2f%2fevil.example', path: '/pwn' },
{ scheme: '%2f%2fevil.example', path: '/pwn' },
{}
),
false,
'equality fails closed for malformed component objects'
)
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
test('URN parsing validates the complete scheme-specific input', (t) => {
const embedded = fastURI.parse('urn:x|foo:bar')
t.equal(embedded.error, 'URN can not be parsed.', 'does not search for a later nid:nss substring')
t.equal(embedded.nid, undefined, 'does not adopt the later nid')
t.equal(embedded.nss, undefined, 'does not adopt the later nss')
const trailing = fastURI.parse('urn:foo:bar|ignored')
t.equal(trailing.error, 'URN can not be parsed.', 'rejects trailing input outside the RFC 2141 NSS grammar')
t.equal(trailing.nid, undefined, 'does not return an nid from a partial match')
t.equal(trailing.nss, undefined, 'does not return a truncated nss')
t.end()
})
test('URN parsing preserves the complete RFC 2141 NSS', (t) => {
const parsed = fastURI.parse('urn:foo:allowed/evil')
t.equal(parsed.error, undefined, 'accepts slash in an RFC 2141 NSS')
t.equal(parsed.nid, 'foo', 'parses the nid')
t.equal(parsed.nss, 'allowed/evil', 'preserves the complete NSS')
t.equal(fastURI.normalize('urn:foo:allowed/evil'), 'urn:foo:allowed/evil', 'normalization retains slash data')
t.equal(fastURI.equal('urn:foo:allowed/evil', 'urn:foo:allowed'), false, 'distinct NSS values do not compare equal')
t.end()
})
'use strict'
const test = require('tape')
const fastURI = require('..')
test('WebSocket queries preserve additional question marks', (t) => {
for (const scheme of ['ws', 'wss']) {
const uri = `${scheme}://example.com/chat?a?b`
const truncatedURI = `${scheme}://example.com/chat?a`
const parsed = fastURI.parse(uri)
t.equal(parsed.resourceName, '/chat?a?b', `${scheme} parse preserves the resource name`)
t.equal(fastURI.serialize(parsed), uri, `${scheme} parsed components round-trip`)
t.equal(
fastURI.serialize({ scheme, host: 'example.com', resourceName: '/chat?a?b' }),
uri,
`${scheme} resource name serializes without truncation`
)
t.equal(fastURI.normalize(uri), uri, `${scheme} normalization preserves the full query`)
t.equal(fastURI.equal(uri, truncatedURI), false, `${scheme} equality distinguishes the full query`)
}
t.end()
})
+1
-1

@@ -150,3 +150,3 @@ name: CI

pull-requests: write
uses: fastify/workflows/.github/workflows/plugins-ci.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
uses: fastify/workflows/.github/workflows/plugins-ci.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0
with:

@@ -153,0 +153,0 @@ license-check: true

@@ -19,2 +19,2 @@ name: Lock Threads

pull-requests: write
uses: fastify/workflows/.github/workflows/lock-threads.yml@2073dc8e1f9e172bf42daa3843c9dbd31af1e8cb # v6.0.0
uses: fastify/workflows/.github/workflows/lock-threads.yml@ef591e2186785d5ab36b9fe6a79c7ce2f1d94e57 # v7.0.0

@@ -13,2 +13,17 @@ import { Bench } from 'tinybench'

const mailtoSimple = 'mailto:chris@example.com'
const mailtoWithSubject = 'mailto:infobot@example.com?subject=current-issue'
const mailtoWithBody = 'mailto:infobot@example.com?body=send%20current-issue%0D%0Asend%20index'
const mailtoWithHeaders = 'mailto:list@example.org?In-Reply-To=%3C3469A91.D10AF4C@example.com%3E'
const mailtoMultiple = 'mailto:joe@example.com,alice@example.org?subject=Test&body=NATTO'
const mailtoEncoded = 'mailto:%22oh%5C%5Cno%22@example.org'
const mailtoComponent = {
scheme: 'mailto',
to: ['chris@example.com'],
subject: 'current-issue',
body: 'send current-issue\r\nsend index',
headers: { 'In-Reply-To': '<3469A91.D10AF4C@example.com>' }
}
const urnuuidComponent = {

@@ -150,2 +165,58 @@ scheme: 'urn',

benchFastUri.add('fast-uri: parse mailto simple', function () {
fastUriParse(mailtoSimple)
})
benchUriJs.add('urijs: parse mailto simple', function () {
uriJsParse(mailtoSimple)
})
benchFastUri.add('fast-uri: parse mailto subject', function () {
fastUriParse(mailtoWithSubject)
})
benchUriJs.add('urijs: parse mailto subject', function () {
uriJsParse(mailtoWithSubject)
})
benchFastUri.add('fast-uri: parse mailto body CRLF', function () {
fastUriParse(mailtoWithBody)
})
benchUriJs.add('urijs: parse mailto body CRLF', function () {
uriJsParse(mailtoWithBody)
})
benchFastUri.add('fast-uri: parse mailto headers', function () {
fastUriParse(mailtoWithHeaders)
})
benchUriJs.add('urijs: parse mailto headers', function () {
uriJsParse(mailtoWithHeaders)
})
benchFastUri.add('fast-uri: parse mailto multi recipient', function () {
fastUriParse(mailtoMultiple)
})
benchUriJs.add('urijs: parse mailto multi recipient', function () {
uriJsParse(mailtoMultiple)
})
benchFastUri.add('fast-uri: parse mailto encoded local', function () {
fastUriParse(mailtoEncoded)
})
benchUriJs.add('urijs: parse mailto encoded local', function () {
uriJsParse(mailtoEncoded)
})
benchFastUri.add('fast-uri: serialize mailto', function () {
fastUriSerialize(mailtoComponent)
})
benchUriJs.add('urijs: serialize mailto', function () {
uriJsSerialize(mailtoComponent)
})
benchFastUri.add('fast-uri: serialize+parse mailto round-trip', function () {
fastUriSerialize(fastUriParse(mailtoWithBody))
})
benchUriJs.add('urijs: serialize+parse mailto round-trip', function () {
uriJsSerialize(uriJsParse(mailtoWithBody))
})
await benchFastUri.run()

@@ -152,0 +223,0 @@ console.log(benchFastUri.name)

+202
-44
'use strict'
const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, normalizeQueryFragmentEncoding, escapePreservingEscapes, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
const { normalizeIPv6, removeDotSegments, recomposeAuthority, normalizePercentEncoding, normalizePathEncoding, serializePathEncoding, normalizeQueryFragmentEncoding, encodeQuery, encodeFragment, reescapeHostDelimiters, isIPv4, nonSimpleDomain } = require('./lib/utils')
const { SCHEMES, getSchemeHandler } = require('./lib/schemes')
const VALID_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*$/u
const MALFORMED_SCHEME_ERROR = 'URI scheme is malformed.'
/**
* @param {string} scheme
* @returns {string}
*/
function decodeValidScheme (scheme) {
const decodedScheme = unescape(String(scheme))
if (!VALID_SCHEME.test(decodedScheme)) {
throw new TypeError(MALFORMED_SCHEME_ERROR)
}
return decodedScheme
}
/**
* @template {import('./types/index').URIComponent|string} T

@@ -29,8 +44,46 @@ * @param {T} uri

const schemelessOptions = options ? Object.assign({ scheme: 'null' }, options) : { scheme: 'null' }
const { parsed: baseParsed, malformedAuthorityOrPort: baseMalformed } = parseWithStatus(baseURI, schemelessOptions)
const { parsed: relativeParsed, malformedAuthorityOrPort: relativeMalformed } = parseWithStatus(relativeURI, schemelessOptions)
if (baseMalformed || relativeMalformed) {
const {
parsed: baseParsed,
malformedAuthorityOrPort: baseMalformed,
malformedPercentEncoding: baseMalformedPercentEncoding,
malformedSchemeSpecific: baseMalformedSchemeSpecific,
malformedHost: baseMalformedHost,
malformedScheme: baseMalformedScheme
} = parseWithStatus(baseURI, schemelessOptions)
const {
parsed: relativeParsed,
malformedAuthorityOrPort: relativeMalformed,
malformedPercentEncoding: relativeMalformedPercentEncoding,
malformedSchemeSpecific: relativeMalformedSchemeSpecific,
malformedHost: relativeMalformedHost,
malformedScheme: relativeMalformedScheme
} = parseWithStatus(relativeURI, schemelessOptions)
if (
baseMalformed ||
relativeMalformed ||
baseMalformedPercentEncoding ||
relativeMalformedPercentEncoding ||
baseMalformedSchemeSpecific ||
relativeMalformedSchemeSpecific ||
baseMalformedHost ||
relativeMalformedHost ||
baseMalformedScheme ||
relativeMalformedScheme
) {
throw new Error(baseParsed.error || relativeParsed.error || 'URI is malformed.')
}
const resolved = resolveComponent(baseParsed, relativeParsed, schemelessOptions, true)
const resolvedSchemeHandler = getSchemeHandler((options && options.scheme) || resolved.scheme)
const resolvedHost = resolved.host
const resolvedHostIsIP = resolvedHost !== undefined && resolvedHost !== '' &&
(isIPv4(resolvedHost) || normalizeIPv6(resolvedHost).isIPV6)
canonicalizeHost(resolved, options || {}, resolvedSchemeHandler, resolvedHostIsIP)
// Percent escapes in an ASCII reg-name are encoded data. The WHATWG hostname
// parser can reject them even though fast-uri preserves them safely as RFC
// 3986 data. A raw non-ASCII host must still fail closed if conversion fails.
const encodedASCIIHost = resolvedHost && resolvedHost.indexOf('%') !== -1 &&
!/\P{ASCII}/u.test(resolvedHost)
if (resolved.error && !encodedASCIIHost) {
throw new Error(resolved.error)
}
schemelessOptions.skipEscape = true

@@ -118,3 +171,3 @@ return serialize(resolved, schemelessOptions)

return normalizedA !== undefined && normalizedB !== undefined && normalizedA.toLowerCase() === normalizedB.toLowerCase()
return normalizedA !== undefined && normalizedB !== undefined && normalizedA === normalizedB
}

@@ -142,2 +195,6 @@

secure: cmpts.secure,
to: cmpts.to,
subject: cmpts.subject,
body: cmpts.body,
headers: cmpts.headers,
error: ''

@@ -148,2 +205,6 @@ }

if (component.scheme) {
component.scheme = decodeValidScheme(component.scheme)
}
// find scheme handler

@@ -155,9 +216,8 @@ const schemeHandler = getSchemeHandler(options.scheme || component.scheme)

const hasAuthority = component.userinfo !== undefined || component.host !== undefined || component.port !== undefined
const pathNoScheme = !options.skipEscape && component.scheme === undefined && !hasAuthority
if (component.path !== undefined) {
if (!options.skipEscape) {
component.path = escapePreservingEscapes(component.path)
if (component.scheme !== undefined) {
component.path = component.path.split('%3A').join(':')
}
component.path = serializePathEncoding(component.path, pathNoScheme)
} else {

@@ -169,2 +229,4 @@ component.path = normalizePercentEncoding(component.path)

if (options.reference !== 'suffix' && component.scheme) {
// Scheme handlers may replace the scheme during serialization.
component.scheme = decodeValidScheme(component.scheme)
uriTokens.push(component.scheme, ':')

@@ -192,2 +254,9 @@ }

// Dot-segment removal can expose a colon that was not originally in the
// first segment (for example, "./a:b"). Reapply path-noscheme encoding so
// the serialized relative reference cannot be reparsed as a URI scheme.
if (pathNoScheme) {
s = serializePathEncoding(s, true)
}
if (

@@ -206,7 +275,7 @@ authority === undefined &&

if (component.query !== undefined) {
uriTokens.push('?', component.query)
uriTokens.push('?', encodeQuery(component.query))
}
if (component.fragment !== undefined) {
uriTokens.push('#', component.fragment)
uriTokens.push('#', encodeFragment(component.fragment))
}

@@ -249,5 +318,69 @@ return uriTokens.join('')

/**
* Checks percent syntax without decoding the represented octets. RFC 3986
* percent-encoding is byte-oriented, so sequences such as `%FF` are valid even
* though they are not independently valid UTF-8.
*
* @param {string|undefined} component
* @returns {boolean}
*/
function hasMalformedPercentEncoding (component) {
if (component === undefined) return false
let percent = component.indexOf('%')
while (percent !== -1) {
if (percent + 2 >= component.length || !/^[\da-f]{2}$/iu.test(component.slice(percent + 1, percent + 3))) {
return true
}
percent = component.indexOf('%', percent + 3)
}
return false
}
/**
* @param {RegExpMatchArray} matches
* @returns {boolean}
*/
function hasMalformedComponentPercentEncoding (matches) {
// Bracketed IP literals use a raw "%" as the zone separator for historical
// compatibility. Their parsing is intentionally left to normalizeIPv6.
const host = matches[4]
return hasMalformedPercentEncoding(matches[3]) ||
(host !== undefined && !(host[0] === '[' && host[host.length - 1] === ']') && hasMalformedPercentEncoding(host)) ||
hasMalformedPercentEncoding(matches[6]) ||
hasMalformedPercentEncoding(matches[7]) ||
hasMalformedPercentEncoding(matches[8])
}
/**
* @param {import('./types/index').URIComponent} parsed
* @param {import('./types/index').Options} options
* @param {{ domainHost?: boolean, unicodeSupport?: boolean }|undefined} schemeHandler
* @param {boolean} isIP
* @returns {boolean} whether host conversion failed
*/
function canonicalizeHost (parsed, options, schemeHandler, isIP) {
if (
!options.unicodeSupport &&
(!schemeHandler || !schemeHandler.unicodeSupport) &&
parsed.host &&
parsed.host[0] !== '[' &&
(options.domainHost || (schemeHandler && schemeHandler.domainHost)) &&
isIP === false &&
nonSimpleDomain(parsed.host)
) {
try {
parsed.host = new URL('http://' + parsed.host).hostname
} catch (e) {
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
return true
}
}
return false
}
/**
* @param {string} uri
* @param {import('./types/index').Options} [opts]
* @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean }}
* @returns {{ parsed: import('./types/index').URIComponent, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
*/

@@ -268,2 +401,7 @@ function parseWithStatus (uri, opts) {

let malformedAuthorityOrPort = false
let malformedPercentEncoding = false
let malformedSchemeSpecific = false
let malformedHost = false
let malformedIPLiteral = false
let malformedScheme = false

@@ -318,3 +456,3 @@ let isIP = false

// store each component
parsed.scheme = matches[1] === undefined ? undefined : matches[1].toLowerCase()
parsed.scheme = matches[1]
parsed.userinfo = matches[3]

@@ -327,2 +465,17 @@ parsed.host = matches[4]

if (parsed.scheme !== undefined) {
const decodedScheme = unescape(parsed.scheme)
if (VALID_SCHEME.test(decodedScheme)) {
parsed.scheme = decodedScheme.toLowerCase()
} else {
parsed.error = parsed.error || MALFORMED_SCHEME_ERROR
malformedScheme = true
}
}
malformedPercentEncoding = hasMalformedComponentPercentEncoding(matches)
if (malformedPercentEncoding) {
parsed.error = parsed.error || 'URI contains malformed percent-encoding.'
}
// fix port number

@@ -342,5 +495,12 @@ if (isNaN(parsed.port)) {

if (ipv4result === false) {
const bracketedIPLiteral = parsed.host[0] === '[' && parsed.host[parsed.host.length - 1] === ']'
const ipv6result = normalizeIPv6(parsed.host)
parsed.host = ipv6result.host.toLowerCase()
isIP = ipv6result.isIPV6
isIP = ipv6result.isIPV6 || ipv6result.isIPVFuture === true
malformedIPLiteral = bracketedIPLiteral && ipv6result.error === true
parsed.host = isIP ? ipv6result.host : ipv6result.host.toLowerCase()
if (malformedIPLiteral) {
parsed.error = parsed.error || 'URI host is malformed.'
malformedAuthorityOrPort = true
}
} else {

@@ -368,24 +528,11 @@ isIP = true

// check if scheme can't handle IRIs
if (!options.unicodeSupport && (!schemeHandler || !schemeHandler.unicodeSupport)) {
// if host component is a domain name
if (parsed.host && (options.domainHost || (schemeHandler && schemeHandler.domainHost)) && isIP === false && nonSimpleDomain(parsed.host)) {
// convert Unicode IDN -> ASCII IDN
try {
parsed.host = new URL('http://' + parsed.host).hostname
} catch (e) {
parsed.error = parsed.error || "Host's domain name can not be converted to ASCII: " + e
}
}
// convert IRI -> URI
}
// convert Unicode IDN -> ASCII IDN when the effective scheme uses domain hosts
malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP)
if (!schemeHandler || (schemeHandler && !schemeHandler.skipNormalize)) {
if (uri.indexOf('%') !== -1) {
if (parsed.scheme !== undefined) {
parsed.scheme = unescape(parsed.scheme)
if (parsed.host !== undefined && !malformedIPLiteral) {
const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true)
parsed.host = reescapeHostDelimiters(host, isIP)
}
if (parsed.host !== undefined) {
parsed.host = reescapeHostDelimiters(unescape(parsed.host), isIP)
}
}

@@ -406,2 +553,5 @@ if (parsed.path) {

schemeHandler.parse(parsed, options)
if (schemeHandler === SCHEMES.urn && parsed.nid === undefined) {
malformedSchemeSpecific = true
}
}

@@ -411,3 +561,3 @@ } else {

}
return { parsed, malformedAuthorityOrPort }
return { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme }
}

@@ -436,9 +586,13 @@

* @param {import('./types/index').Options} [opts]
* @returns {{ normalized: string, malformedAuthorityOrPort: boolean }}
* @returns {{ normalized: string, malformedAuthorityOrPort: boolean, malformedPercentEncoding: boolean, malformedSchemeSpecific: boolean, malformedHost: boolean, malformedScheme: boolean }}
*/
function normalizeStringWithStatus (uri, opts) {
const { parsed, malformedAuthorityOrPort } = parseWithStatus(uri, opts)
const { parsed, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = parseWithStatus(uri, opts)
return {
normalized: malformedAuthorityOrPort ? uri : serialize(parsed, opts),
malformedAuthorityOrPort
normalized: malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? uri : serialize(parsed, opts),
malformedAuthorityOrPort,
malformedPercentEncoding,
malformedSchemeSpecific,
malformedHost,
malformedScheme
}

@@ -453,10 +607,14 @@ }

function normalizeComparableURI (uri, opts) {
if (typeof uri === 'string') {
const { normalized, malformedAuthorityOrPort } = normalizeStringWithStatus(uri, opts)
return malformedAuthorityOrPort ? undefined : normalized
if (typeof uri !== 'string' && typeof uri !== 'object') {
return undefined
}
if (typeof uri === 'object') {
return serialize(uri, opts)
let value
try {
value = typeof uri === 'string' ? uri : serialize(uri, opts)
} catch {
return undefined
}
const { normalized, malformedAuthorityOrPort, malformedPercentEncoding, malformedSchemeSpecific, malformedHost, malformedScheme } = normalizeStringWithStatus(value, opts)
return malformedAuthorityOrPort || malformedPercentEncoding || malformedSchemeSpecific || malformedHost || malformedScheme ? undefined : normalized
}

@@ -463,0 +621,0 @@

'use strict'
const { isUUID } = require('./utils')
const URN_REG = /([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu
const { isUUID, BYTE_HEX, percentEncodeNonAscii, nonSimpleMailtoDomain } = require('./utils')
const URN_REG = /^([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-./:;=@]|%[\da-f]{2})+)$/iu
const supportedSchemeNames = /** @type {const} */ (['http', 'https', 'ws',
'wss', 'urn', 'urn:uuid'])
'wss', 'urn', 'urn:uuid', 'mailto'])

@@ -116,5 +116,10 @@ /** @typedef {supportedSchemeNames[number]} SchemeName */

if (wsComponent.resourceName) {
const [path, query] = wsComponent.resourceName.split('?')
const queryIndex = wsComponent.resourceName.indexOf('?')
const path = queryIndex === -1
? wsComponent.resourceName
: wsComponent.resourceName.slice(0, queryIndex)
wsComponent.path = (path && path !== '/' ? path : undefined)
wsComponent.query = query
wsComponent.query = queryIndex === -1
? undefined
: wsComponent.resourceName.slice(queryIndex + 1)
wsComponent.resourceName = undefined

@@ -136,3 +141,3 @@ }

const matches = urnComponent.path.match(URN_REG)
if (matches) {
if (matches && matches[0] === urnComponent.path) {
const scheme = options.scheme || urnComponent.scheme || 'urn'

@@ -240,2 +245,373 @@ urnComponent.nid = matches[1].toLowerCase()

/**
* Allow-sets for `encodeWithAllow`, each a `{ all, table, allowNonAscii }`
* descriptor built by `allowSet`:
*
* - `LOCAL_PART` is the intersection of upstream uri-js's VCHAR and
* NOT_PATH_NOSCHEME, minus ",": the serializer joins recipients with "," and
* `mailtoParse` splits the path on it, so a comma left literal inside a local
* part would silently become a recipient delimiter.
* - `HFNAME` is the qchar allow-set used for header names/values.
* - `DTEXT` is RFC 6068 dtext-no-obs restricted to characters that are also
* safe in a URI path; `DTEXT_IRI` additionally permits non-ASCII for
* `unicodeSupport`.
*/
const LOCAL_PART_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+="
const HFNAME_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+,;:@"
const DTEXT_ALLOWED = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~!$'()*+;:"
/**
* @typedef {Object} AllowSet
* @property {RegExp} all - matches a whole string needing no encoding at all.
* @property {Uint8Array} table - per-ASCII-code allow lookup for the slow path.
* @property {boolean} allowNonAscii - pass non-ASCII code points through (IRI).
*/
/**
* `all` deliberately excludes "%" and (for the IRI variant) surrogates, so any
* input needing escape normalization or surrogate repair falls to the slow path.
*
* @param {string} chars
* @param {boolean} [allowNonAscii=false]
* @returns {AllowSet}
*/
function allowSet (chars, allowNonAscii = false) {
const table = new Uint8Array(128)
for (let i = 0; i < chars.length; i++) {
table[chars.charCodeAt(i)] = 1
}
const escaped = chars.replace(/[\\\]^-]/gu, '\\$&')
const cls = allowNonAscii ? `[${escaped}]|[^\\0-\\x7F\\uD800-\\uDFFF]` : `[${escaped}]`
return { all: new RegExp(`^(?:${cls})*$`, 'u'), table, allowNonAscii }
}
const LOCAL_PART = allowSet(LOCAL_PART_ALLOWED)
const HFNAME = allowSet(HFNAME_ALLOWED)
const DTEXT = allowSet(DTEXT_ALLOWED)
const DTEXT_IRI = allowSet(DTEXT_ALLOWED, true)
const HEX_PAIR = /^[\da-f]{2}$/iu
const MAILTO_DOMAIN_LITERAL = /^\[[\x21-\x5A\x5E-\x7E]*\]$/u
const MAILTO_DOMAIN_ERROR = 'URI mailto has an invalid recipient domain.'
const HAS_SURROGATE = /[\uD800-\uDFFF]/u
/**
* @param {string} str
* @returns {string}
*/
function decodeHex (str) {
if (typeof str !== 'string' || str.indexOf('%') === -1) {
return str
}
try {
return decodeURIComponent(str)
} catch {
return str
}
}
/**
* Replaces lone surrogates with U+FFFD, leaving valid pairs intact. Only called
* when `HAS_SURROGATE` matched, so the scan cost is not on the common path.
* (`String.prototype.toWellFormed` would do this natively but is Node 20+.)
*
* @param {string} input
* @returns {string}
*/
function replaceLoneSurrogates (input) {
let result = ''
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i)
if (code >= 0xD800 && code <= 0xDBFF && i + 1 < input.length) {
const low = input.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
result += input[i] + input[i + 1]
i++
continue
}
}
result += (code >= 0xD800 && code <= 0xDFFF) ? '\uFFFD' : input[i]
}
return result
}
/**
* Percent-encodes everything outside `set`, preserving existing valid escapes
* (uppercased) and repairing lone surrogates.
*
* ASCII uses `BYTE_HEX` rather than `encodeURIComponent`. That is equivalent
* only because every character `encodeURIComponent` leaves unescaped
* (`A-Za-z0-9-_.!~*'()`) is present in all of the allow-sets above, so no such
* character ever reaches the encode branch. Re-check if a set is narrowed.
*
* @param {string} input
* @param {AllowSet} set
* @returns {string}
*/
function encodeWithAllow (input, set) {
if (set.all.test(input)) {
return input
}
const table = set.table
let result = ''
for (let i = 0; i < input.length; i++) {
const code = input.charCodeAt(i)
if (code < 0x80) {
if (table[code] === 1) {
result += input[i]
} else if (code === 0x25 && i + 2 < input.length && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
result += '%' + input.slice(i + 1, i + 3).toUpperCase()
i += 2
} else {
result += BYTE_HEX[code]
}
continue
}
if (code < 0xD800 || code > 0xDFFF) {
result += set.allowNonAscii ? input[i] : percentEncodeNonAscii(code)
continue
}
if (code <= 0xDBFF && i + 1 < input.length) {
const low = input.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
result += set.allowNonAscii
? input[i] + input[i + 1]
: percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
i++
continue
}
}
result += set.allowNonAscii ? '\uFFFD' : percentEncodeNonAscii(0xFFFD)
}
return result
}
/**
* @param {string} domain
* @param {import('../types/index').Options} [options]
* @param {{error?:string}} [component]
* @returns {string}
*/
function mailtoNormalizeDomain (domain, options, component) {
const normalizedDomain = String(domain).toLowerCase()
if (MAILTO_DOMAIN_LITERAL.test(normalizedDomain)) {
return normalizedDomain
}
// `hostname` is the identity for these, so skip constructing a WHATWG URL.
if (normalizedDomain !== '' && !nonSimpleMailtoDomain(normalizedDomain)) {
return normalizedDomain
}
try {
const parsedDomain = new URL('http://' + normalizedDomain)
if (
parsedDomain.username ||
parsedDomain.password ||
parsedDomain.port ||
parsedDomain.pathname !== '/' ||
parsedDomain.search ||
parsedDomain.hash ||
!parsedDomain.hostname
) {
throw new Error(MAILTO_DOMAIN_ERROR)
}
return options && options.unicodeSupport
? normalizedDomain
: parsedDomain.hostname
} catch {
if (component) {
component.error = component.error || MAILTO_DOMAIN_ERROR
}
return normalizedDomain
}
}
/**
* Percent-encodes everything in a recipient domain that is not `dtext-no-obs`
* (RFC 6068). Serialize-only: `mailtoNormalizeDomain` returns the domain
* verbatim when validation fails, and `MAILTO_DOMAIN_LITERAL` accepts the full
* RFC 5321 dcontent range (which includes "?", "#", "&" and "/"), so without
* this an attacker-supplied recipient could inject header fields or a fragment
* into the serialized URI. `mailtoParse` deliberately reports the raw domain
* instead, so it must not use this.
* @param {string} domain
* @param {import('../types/index').Options} [options]
* @returns {string}
*/
function mailtoEncodeDomain (domain, options) {
const set = options && options.unicodeSupport ? DTEXT_IRI : DTEXT
// Keep the delimiters of a domain literal, encode its contents.
if (domain.length > 1 && domain[0] === '[' && domain[domain.length - 1] === ']') {
return '[' + encodeWithAllow(domain.slice(1, -1), set) + ']'
}
return encodeWithAllow(domain, set)
}
/**
* @param {import('../types/index').URIComponent} component
* @param {import('../types/index').Options} options
* @returns {import('../types/index').URIComponent}
*/
function mailtoParse (component, options) {
const mailtoComponent = component
// The handler sets `skipNormalize`, so path and query arrive raw. Everything
// here goes through `decodeHex`, which subsumes the generic normalizers --
// except that they also fold lone surrogates to U+FFFD, so do that here.
let rawPath = mailtoComponent.path
let rawQuery = mailtoComponent.query
if (rawPath && HAS_SURROGATE.test(rawPath)) rawPath = replaceLoneSurrogates(rawPath)
if (rawQuery && HAS_SURROGATE.test(rawQuery)) rawQuery = replaceLoneSurrogates(rawQuery)
const to = rawPath
? (rawPath.indexOf(',') === -1 ? [rawPath] : rawPath.split(','))
: []
mailtoComponent.path = undefined
if (rawQuery) {
// Null prototype: header names come from untrusted input, so `headers[name]`
// must not resolve to inherited members ("constructor", "toString", ...),
// and "__proto__" has to land as a plain own property instead of hitting
// Object.prototype's setter and being silently dropped. Allocated lazily so
// the common subject/body-only URI does not pay for it.
/** @type {Record<string,string>|null} */
let headers = null
let start = 0
while (start <= rawQuery.length) {
let end = rawQuery.indexOf('&', start)
if (end === -1) end = rawQuery.length
const token = rawQuery.slice(start, end)
start = end + 1
const eqIdx = token.indexOf('=')
if (eqIdx !== token.lastIndexOf('=')) {
mailtoComponent.error = mailtoComponent.error || 'URI mailto has malformed header fields.'
continue
}
const name = eqIdx === -1 ? token : token.slice(0, eqIdx)
const value = eqIdx === -1 ? '' : token.slice(eqIdx + 1)
if (name === 'to') {
const addrs = value.split(',')
for (let j = 0; j < addrs.length; j++) to.push(addrs[j])
continue
}
if (name === 'subject') {
mailtoComponent.subject = decodeHex(value)
continue
}
if (name === 'body') {
mailtoComponent.body = decodeHex(value)
continue
}
if (headers === null) headers = /** @type {Record<string,string>} */ (Object.create(null))
headers[decodeHex(name)] = decodeHex(value)
}
if (headers !== null) mailtoComponent.headers = headers
}
mailtoComponent.query = undefined
for (let i = 0; i < to.length; i++) {
const rawAddr = to[i]
const atIdx = rawAddr.lastIndexOf('@')
if (atIdx < 0) {
mailtoComponent.error = mailtoComponent.error || MAILTO_DOMAIN_ERROR
to[i] = decodeHex(rawAddr)
continue
}
const local = decodeHex(rawAddr.slice(0, atIdx))
const domain = mailtoNormalizeDomain(decodeHex(rawAddr.slice(atIdx + 1)), options, mailtoComponent)
to[i] = local + '@' + domain
}
if (to.length) mailtoComponent.to = to
return mailtoComponent
}
/**
* @param {import('../types/index').URIComponent} component
* @param {import('../types/index').Options} options
* @returns {import('../types/index').URIComponent}
*/
function mailtoSerialize (component, options) {
const mailtoComponent = component
const to = Array.isArray(mailtoComponent.to) ? mailtoComponent.to.slice() : []
if (to.length) {
for (let i = 0; i < to.length; i++) {
const addr = String(to[i])
const atIdx = addr.lastIndexOf('@')
const rawLocal = atIdx >= 0 ? addr.slice(0, atIdx) : addr
const rawDomain = atIdx >= 0 ? addr.slice(atIdx + 1) : ''
const local = encodeWithAllow(rawLocal, LOCAL_PART)
const decodedDomain = decodeHex(rawDomain)
// `component` is intentionally not passed: index.js's `serialize` writes
// the error onto a private copy and returns only a string, so an error
// recorded here would be unreachable. Safety comes from encoding instead.
const normalizedDomain = mailtoNormalizeDomain(decodedDomain, options)
to[i] = local + '@' + mailtoEncodeDomain(normalizedDomain, options)
}
// Every recipient is now fully encoded and only the joining commas are
// literal, so bypass index.js's generic encoder, whose allow-set is
// narrower than upstream's and would over-encode.
mailtoComponent.path = to.join(',')
options.skipEscape = true
} else {
// No recipients: drop any caller-supplied path, matching upstream uri-js.
// `to` is the only recipient source, and `mailtoParse` always moves the
// path into it, so a mailto component never legitimately carries one.
mailtoComponent.path = undefined
}
const headers = mailtoComponent.headers && typeof mailtoComponent.headers === 'object'
? Object.assign(Object.create(null), mailtoComponent.headers)
: Object.create(null)
if (mailtoComponent.subject) headers.subject = mailtoComponent.subject
if (mailtoComponent.body) headers.body = mailtoComponent.body
mailtoComponent.headers = headers
// `headers` has a null prototype and was filled via Object.assign, so every
// enumerable key is an own property and no hasOwnProperty guard is needed.
let query = ''
let count = 0
for (const name in headers) {
if (count++ !== 0) query += '&'
query += encodeWithAllow(name, HFNAME) + '=' + encodeWithAllow(String(headers[name]), HFNAME)
}
if (count !== 0) {
mailtoComponent.query = query
} else {
mailtoComponent.headers = undefined
}
return mailtoComponent
}
const mailto = /** @type {SchemeHandler} */ ({
scheme: 'mailto',
parse: mailtoParse,
serialize: mailtoSerialize,
domainHost: false,
unicodeSupport: true,
// `mailtoParse` re-derives every component from the raw path/query via
// `decodeHex`, which subsumes the generic normalizers, so running them first
// is wasted work (~28% of parsing a URI with header fields).
skipNormalize: true,
// A recipient list has no dot segments to remove, and `removeDotSegments`
// would rewrite a "./"-prefixed local part.
absolutePath: true
})
const SCHEMES = /** @type {Record<SchemeName, SchemeHandler>} */ ({

@@ -247,3 +623,4 @@ http,

urn,
'urn:uuid': urnuuid
'urn:uuid': urnuuid,
mailto
})

@@ -250,0 +627,0 @@

@@ -16,7 +16,10 @@ 'use strict'

/** @type {(value: string) => boolean} */
const isPathCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/]$/iu)
const isPathCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/]$/u)
/** @type {(value: string) => boolean} */
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[\da-z\-._~!$&'()*+,;=:@/?]$/iu)
const isQueryFragmentCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u)
/** @type {(value: string) => boolean} */
const isUserinfoCharacter = RegExp.prototype.test.bind(/^[A-Za-z0-9\-._~!$&'()*+,;=:]$/u)
/**

@@ -53,9 +56,11 @@ * @param {Array<string>} input

/**
* @typedef {Object} GetIPV6Result
* @property {boolean} error - Indicates if there was an error parsing the IPv6 address.
* @property {string} address - The parsed IPv6 address.
* @property {string} [zone] - The zone identifier, if present.
*/
/** @type {(value: string) => boolean} */
const isHextet = RegExp.prototype.test.bind(/^[\dA-Fa-f]{1,4}$/)
/** @type {(value: string) => boolean} */
const isIPvFuture = RegExp.prototype.test.bind(/^[vV][\dA-Fa-f]+\.[A-Za-z\d\-._~!$&'()*+,;=:]+$/)
/** @type {(value: string) => boolean} */
const isZoneCharacter = RegExp.prototype.test.bind(/^[A-Za-z\d\-._~]$/)
/**

@@ -67,28 +72,42 @@ * @param {string} value

/** @type {(value: string) => boolean} */
const nonSimpleDomainChars = RegExp.prototype.test.bind(/[^\d!"$&'()*+,\-.;=_`a-z{}~]/u)
/** @type {(value: string) => boolean} */
const numericLastLabel = RegExp.prototype.test.bind(/(?:^|\.)\d+$/u)
/**
* @param {Array<string>} buffer
* True when `new URL('http://' + domain).hostname` may differ from `domain`,
* i.e. when the caller must fall back to the WHATWG parser.
*
* Same purpose as `nonSimpleDomain`, but digits are allowed: for this character
* set `hostname` is the identity, *except* that an all-numeric final label is
* read as IPv4 shorthand ("1.2.3" -> "1.2.0.3", "127.1" -> "127.0.0.1",
* "a.b.1" throws), which is why `nonSimpleDomain` excludes digits outright.
* Rejecting that one shape lets the common `mail2.example.org` case stay on the
* fast path.
*
* @param {string} domain - must already be lowercased
* @returns {boolean}
*/
function consumeIsZone (buffer) {
buffer.length = 0
return true
function nonSimpleMailtoDomain (domain) {
return nonSimpleDomainChars(domain) || numericLastLabel(domain)
}
/**
* @param {Array<string>} buffer
* @param {Array<string>} address
* @param {GetIPV6Result} output
* @param {string} zone
* @returns {boolean}
*/
function consumeHextets (buffer, address, output) {
if (buffer.length) {
const hex = stringArrayToHexStripped(buffer)
if (hex !== '') {
address.push(hex)
} else {
output.error = true
return false
function isZoneIdentifier (zone) {
if (zone.length === 0) return false
for (let i = 0; i < zone.length; i++) {
if (isZoneCharacter(zone[i])) continue
if (zone[i] === '%' && i + 2 < zone.length && isHexPair(zone.slice(i + 1, i + 3))) {
i += 2
continue
}
buffer.length = 0
return false
}
return true

@@ -98,55 +117,81 @@ }

/**
* Compresses the longest run of zero hextets to "::" per RFC 5952. A run of a
* single zero hextet is left uncompressed. On ties the leftmost run wins.
*
* @param {string[]} hextets
* @returns {string}
*/
function compressIPv6ZeroRun (hextets) {
let bestStart = -1
let bestLength = 0
let runStart = -1
let runLength = 0
for (let i = 0; i < hextets.length; i++) {
if (hextets[i] === '0') {
if (runStart === -1) runStart = i
runLength++
if (runLength > bestLength) {
bestLength = runLength
bestStart = runStart
}
} else {
runStart = -1
runLength = 0
}
}
if (bestLength < 2) return hextets.join(':')
const head = hextets.slice(0, bestStart).join(':')
const tail = hextets.slice(bestStart + bestLength).join(':')
return head + '::' + tail
}
/**
* Validates an IPv6 address against the alternatives in RFC 3986 section
* 3.2.2 and returns the same address with leading hextet zeroes removed.
* An embedded IPv4 address counts as two hextets and is only valid at the end.
*
* @param {string} input
* @returns {GetIPV6Result}
* @returns {string|undefined}
*/
function getIPV6 (input) {
let tokenCount = 0
const output = { error: false, address: '', zone: '' }
/** @type {Array<string>} */
const address = []
/** @type {Array<string>} */
const buffer = []
let endipv6Encountered = false
let endIpv6 = false
function normalizeIPv6Address (input) {
const compression = input.indexOf('::')
if (compression !== -1 && input.indexOf('::', compression + 1) !== -1) return undefined
let consume = consumeHextets
const left = compression === -1 ? input.split(':') : input.slice(0, compression).split(':')
const right = compression === -1 ? [] : input.slice(compression + 2).split(':')
if (compression !== -1) {
if (left.length === 1 && left[0] === '') left.length = 0
if (right.length === 1 && right[0] === '') right.length = 0
}
for (let i = 0; i < input.length; i++) {
const cursor = input[i]
if (cursor === '[' || cursor === ']') { continue }
if (cursor === ':') {
if (endipv6Encountered === true) {
endIpv6 = true
}
if (!consume(buffer, address, output)) { break }
if (++tokenCount > 7) {
// not valid
output.error = true
break
}
if (i > 0 && input[i - 1] === ':') {
endipv6Encountered = true
}
address.push(':')
const parts = left.concat(right)
let hextetCount = 0
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (part === '') return undefined
if (part.indexOf('.') !== -1) {
if (i !== parts.length - 1 || (compression !== -1 && right.length === 0) || !isIPv4(part)) return undefined
hextetCount += 2
continue
} else if (cursor === '%') {
if (!consume(buffer, address, output)) { break }
// switch to zone detection
consume = consumeIsZone
} else {
buffer.push(cursor)
continue
}
if (!isHextet(part)) return undefined
parts[i] = parseInt(part, 16).toString(16)
hextetCount++
}
if (buffer.length) {
if (consume === consumeIsZone) {
output.zone = buffer.join('')
} else if (endIpv6) {
address.push(buffer.join(''))
} else {
address.push(stringArrayToHexStripped(buffer))
}
if (compression === -1) {
if (hextetCount !== 8) return undefined
return compressIPv6ZeroRun(parts)
}
output.address = address.join('')
return output
if (hextetCount >= 8) return undefined
// expand "::" then re-compress the longest run for a canonical result
const expanded = parts.slice(0, left.length)
for (let i = hextetCount; i < 8; i++) expanded.push('0')
for (let i = left.length; i < parts.length; i++) expanded.push(parts[i])
return compressIPv6ZeroRun(expanded)
}

@@ -159,5 +204,11 @@

* @property {boolean} isIPV6 - Indicates if the host is an IPv6 address.
* @property {boolean} [isIPVFuture] - Indicates if the host is an IPvFuture literal.
* @property {boolean} [error] - Indicates if a bracketed IP literal is malformed.
*/
/**
* Validates and normalizes a bracketed IP literal. Raw zone separators remain
* accepted for backwards compatibility, while encoded separators and zone
* contents follow RFC 6874.
*
* @param {string} host

@@ -167,16 +218,33 @@ * @returns {NormalizeIPv6Result}

function normalizeIPv6 (host) {
if (findToken(host, ':') < 2) { return { host, isIPV6: false } }
const ipv6 = getIPV6(host)
const bracketed = host[0] === '[' && host[host.length - 1] === ']'
const hasBracket = host[0] === '[' || host[host.length - 1] === ']'
if (hasBracket && !bracketed) return { host, isIPV6: false, error: true }
if (!ipv6.error) {
let newHost = ipv6.address
let escapedHost = ipv6.address
if (ipv6.zone) {
newHost += '%' + ipv6.zone
escapedHost += '%25' + ipv6.zone
}
return { host: newHost, isIPV6: true, escapedHost }
} else {
return { host, isIPV6: false }
let input = bracketed ? host.slice(1, -1) : host
if (bracketed && isIPvFuture(input)) {
input = input.toLowerCase()
return { host: `[${input}]`, escapedHost: input, isIPV6: false, isIPVFuture: true }
}
if (findToken(input, ':') < 2) {
return { host, isIPV6: false, error: bracketed }
}
let zoneIdentifier = ''
const zoneSeparator = input.indexOf('%')
if (zoneSeparator !== -1) {
const separatorLength = input.slice(zoneSeparator, zoneSeparator + 3).toLowerCase() === '%25' ? 3 : 1
zoneIdentifier = input.slice(zoneSeparator + separatorLength)
if (!isZoneIdentifier(zoneIdentifier)) return { host, isIPV6: false, error: true }
input = input.slice(0, zoneSeparator)
}
const address = normalizeIPv6Address(input)
if (address === undefined) return { host, isIPV6: false, error: true }
return {
host: address + (zoneIdentifier ? '%' + zoneIdentifier : ''),
escapedHost: address + (zoneIdentifier ? '%25' + zoneIdentifier : ''),
isIPV6: true
}
}

@@ -306,3 +374,3 @@

* Normalizes percent escapes and optionally decodes only unreserved ASCII bytes.
* Reserved delimiters such as `%2F` and `%2E` stay escaped.
* Reserved delimiters such as `%2F` stay escaped; `%2E` is unreserved.
*

@@ -449,2 +517,56 @@ * @param {string} input

/**
* Serializes a path without rewriting reserved data. Raw RFC 3986 path
* characters remain literal, valid escapes are preserved and uppercased, and
* everything else is UTF-8 percent-encoded. In a path-noscheme, a colon in the
* first segment must be escaped so the result cannot be parsed as a scheme.
*
* @param {string} input
* @param {boolean} [pathNoScheme=false]
* @returns {string}
*/
function serializePathEncoding (input, pathNoScheme = false) {
let output = ''
let firstSegment = pathNoScheme && input[0] !== '/'
for (let i = 0; i < input.length; i++) {
const ch = input[i]
if (ch === '%' && i + 2 < input.length) {
const hex = input.slice(i + 1, i + 3)
if (isHexPair(hex)) {
output += '%' + hex.toUpperCase()
i += 2
continue
}
}
if (ch === '/') {
firstSegment = false
}
if (isPathCharacter(ch) && (ch !== ':' || !firstSegment)) {
output += ch
} else {
const code = input.charCodeAt(i)
if (code < 0x80) {
output += BYTE_HEX[code]
} else if (code < 0xD800 || code > 0xDFFF) {
output += percentEncodeNonAscii(code)
} else if (code <= 0xDBFF && i + 1 < input.length) {
const low = input.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
i++
} else {
output += percentEncodeNonAscii(0xFFFD)
}
} else {
output += percentEncodeNonAscii(0xFFFD)
}
}
}
return output
}
/**
* Normalizes the percent-encoding of a query or fragment component.

@@ -507,2 +629,81 @@ *

/**
* Percent-encodes a URI component using its RFC 3986 literal character set.
* Existing valid escapes are preserved and normalized to uppercase hex.
*
* @param {string} input
* @param {(value: string) => boolean} isAllowed
* @returns {string}
*/
function encodeComponent (input, isAllowed) {
let output = ''
for (let i = 0; i < input.length; i++) {
const ch = input[i]
if (ch === '%' && i + 2 < input.length) {
const hex = input.slice(i + 1, i + 3)
if (isHexPair(hex)) {
output += '%' + hex.toUpperCase()
i += 2
continue
}
}
if (isAllowed(ch)) {
output += ch
} else {
const code = input.charCodeAt(i)
if (code < 0x80) {
output += BYTE_HEX[code]
} else if (code < 0xD800 || code > 0xDFFF) {
output += percentEncodeNonAscii(code)
} else if (code <= 0xDBFF && i + 1 < input.length) {
const low = input.charCodeAt(i + 1)
if (low >= 0xDC00 && low <= 0xDFFF) {
output += percentEncodeNonAscii(0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00))
i++
} else {
output += percentEncodeNonAscii(0xFFFD)
}
} else {
output += percentEncodeNonAscii(0xFFFD)
}
}
}
return output
}
/**
* Encodes userinfo while preserving its RFC 3986 §3.2.1 literal characters.
* In particular, authority delimiters such as `@`, `/`, `?`, and `#` are data.
*
* @param {string} input
* @returns {string}
*/
function encodeUserinfo (input) {
return encodeComponent(input, isUserinfoCharacter)
}
/**
* Encodes query data using the RFC 3986 §3.4 grammar. A literal `#` must be
* escaped because it would otherwise begin the fragment component.
*
* @param {string} input
* @returns {string}
*/
function encodeQuery (input) {
return encodeComponent(input, isQueryFragmentCharacter)
}
/**
* Encodes fragment data using the RFC 3986 §3.5 grammar.
*
* @param {string} input
* @returns {string}
*/
function encodeFragment (input) {
return encodeComponent(input, isQueryFragmentCharacter)
}
/**
* Escapes a component while preserving existing valid percent escapes.

@@ -556,3 +757,3 @@ *

if (component.userinfo !== undefined) {
uriTokens.push(component.userinfo)
uriTokens.push(encodeUserinfo(component.userinfo))
uriTokens.push('@')

@@ -562,6 +763,12 @@ }

if (component.host !== undefined) {
let host = unescape(component.host)
let host = component.host
if (!isIPv4(host)) {
const ipV6res = normalizeIPv6(host)
if (ipV6res.isIPV6 === true) {
let ipV6res = normalizeIPv6(host)
if (ipV6res.isIPV6 !== true && ipV6res.isIPVFuture !== true) {
// Decode only unreserved bytes, once. In particular, keep %25 encoded
// so it cannot introduce a second escape during recomposition.
host = normalizePercentEncoding(host, true)
ipV6res = normalizeIPv6(host)
}
if (ipV6res.isIPV6 === true || ipV6res.isIPVFuture === true) {
host = `[${ipV6res.escapedHost}]`

@@ -584,3 +791,6 @@ } else {

module.exports = {
BYTE_HEX,
percentEncodeNonAscii,
nonSimpleDomain,
nonSimpleMailtoDomain,
recomposeAuthority,

@@ -590,3 +800,7 @@ reescapeHostDelimiters,

normalizePathEncoding,
serializePathEncoding,
normalizeQueryFragmentEncoding,
encodeUserinfo,
encodeQuery,
encodeFragment,
escapePreservingEscapes,

@@ -593,0 +807,0 @@ removeDotSegments,

{
"name": "fast-uri",
"description": "Dependency-free RFC 3986 URI toolbox",
"version": "4.1.2",
"version": "4.1.3",
"main": "index.js",

@@ -6,0 +6,0 @@ "type": "commonjs",

@@ -14,6 +14,11 @@ # Security Policy

Differences between `fast-uri` and WHATWG URL implementations are expected.
Reports based on comparing or mixing parsers that follow these different
Reports based solely on comparing or mixing parsers that follow these different
standards are out of scope. Applications must use the same parsing and
normalization rules for both security decisions and subsequent URI use.
Reports are assessed based on reproducibility, documented supported usage, and
concrete security impact—not merely on whether they mention WHATWG URL
behavior. Reports demonstrating a parsing or normalization flaw in a supported
scheme remain eligible for evaluation.
## Threat model

@@ -20,0 +25,0 @@

@@ -24,2 +24,32 @@ 'use strict'

test('URI Equals rejects malformed object input even when identical', (t) => {
const malformed = { scheme: 'http', host: 'example.com', port: 99999, path: '/x' }
t.equal(fn(malformed, malformed), false)
t.end()
})
test('URI Equals preserves case-sensitive components', (t) => {
const suite = [
{ pair: ['http://example.com/Admin', 'http://example.com/admin'], result: false },
{ pair: ['http://example.com/?token=SECRET', 'http://example.com/?token=secret'], result: false },
{ pair: ['http://example.com/#Section', 'http://example.com/#section'], result: false },
{ pair: ['http://User@example.com/', 'http://user@example.com/'], result: false },
{ pair: ['urn:foo:CaseSensitive', 'urn:foo:casesensitive'], result: false },
{ pair: ['ws://example.com/Chat', 'ws://example.com/chat'], result: false },
{ pair: ['ws://example.com/?token=SECRET', 'ws://example.com/?token=secret'], result: false },
{ pair: [{ scheme: 'http', host: 'example.com', path: '/Admin' }, { scheme: 'http', host: 'example.com', path: '/admin' }], result: false }
]
runTest(t, suite)
t.end()
})
test('URI Equals folds normalized scheme and host case', (t) => {
const suite = [
{ pair: ['HTTP://EXAMPLE.COM/resource', 'http://example.com/resource'], result: true },
{ pair: [{ scheme: 'HTTP', host: 'EXAMPLE.COM', path: '/resource' }, { scheme: 'http', host: 'example.com', path: '/resource' }], result: true }
]
runTest(t, suite)
t.end()
})
// test('IRI Equals', (t) => {

@@ -70,5 +100,3 @@ // // example from RFC 3987

t.throws(() => {
fn('urn:', 'urn:FOO:a123,456')
}, 'URN without nid cannot be serialized')
t.equal(fn('urn:', 'urn:FOO:a123,456'), false, 'malformed URN fails equality safely')

@@ -112,2 +140,14 @@ t.end()

test('Mailto Equal normalizes domains without folding message data', (t) => {
const suite = [
{ pair: ['mailto:user@EXAMPLE.ORG', 'mailto:user@example.org'], result: true },
{ pair: ['mailto:User@example.org', 'mailto:user@example.org'], result: false },
{ pair: ['mailto:user@example.org?subject=Hello', 'mailto:user@example.org?subject=hello'], result: false },
{ pair: ['mailto:user@example.org?body=Hello', 'mailto:user@example.org?body=hello'], result: false },
{ pair: ['mailto:user@example.org?x=Hello', 'mailto:user@example.org?x=hello'], result: false }
]
runTest(t, suite)
t.end()
})
test('URI Equals tolerates malformed fragments', (t) => {

@@ -114,0 +154,0 @@ t.equal(

@@ -56,2 +56,3 @@ [

"path": "",
"error": "URI host is malformed.",
"reference": "relative"

@@ -246,2 +247,3 @@ }

"path": "",
"error": "URI host is malformed.",
"reference": "relative"

@@ -502,3 +504,24 @@ }

}
],
[
"mailto:addr1@an.example,addr2@an.example",
{
"scheme": "mailto",
"reference": "absolute",
"to": [
"addr1@an.example",
"addr2@an.example"
]
}
],
[
"mailto:user@example.org?",
{
"scheme": "mailto",
"reference": "absolute",
"to": [
"user@example.org"
]
}
]
]
]

@@ -119,3 +119,25 @@ [

"scheme:with:colon"
],
[
{
"scheme": "mailto",
"to": [
"chris@example.com"
]
},
"mailto:chris@example.com"
],
[
{
"scheme": "mailto",
"to": [
"joe@example.com"
],
"headers": {
"cc": "bob@example.com"
},
"body": "hello"
},
"mailto:joe@example.com?cc=bob@example.com&body=hello"
]
]
]

@@ -217,2 +217,3 @@ 'use strict'

t.equal(components.host, '[2001:dbz::7]')
t.equal(components.error, 'URI host is malformed.')

@@ -322,2 +323,3 @@ // mixed IPv4address & IPv6address

t.equal(components.host, '[2606:2800:220:1:248:1893:25c8:1946:43209]')
t.equal(components.error, 'URI host is malformed.')

@@ -328,1 +330,144 @@ components = fastURI.parse('urn:foo:|\\24fpl')

})
test('Mailto parsing normalizes Unicode domains according to options', (t) => {
t.deepEqual(
fastURI.parse('mailto:user@納豆.example.org').to,
['user@xn--99zt52a.example.org'],
'Unicode domain defaults to ASCII'
)
t.deepEqual(
fastURI.parse('mailto:user@納豆.example.org', { unicodeSupport: true }).to,
['user@納豆.example.org'],
'Unicode domain is preserved when requested'
)
t.deepEqual(
fastURI.parse('mailto:user@xn--99zt52a.example.org', { unicodeSupport: true }).to,
['user@xn--99zt52a.example.org'],
'existing punycode remains unchanged in Unicode mode'
)
t.end()
})
test('Mailto parsing preserves and reports malformed recipient domains', (t) => {
const cases = [
['mailto:user@example.org:25', 'user@example.org:25'],
['mailto:user@example.org/path', 'user@example.org/path'],
['mailto:user@example.org%40evil.test', 'user@example.org@evil.test'],
['mailto:user@example.org%3Fquery', 'user@example.org?query'],
['mailto:user@example.org%23fragment', 'user@example.org#fragment'],
['mailto:user@[broken', 'user@[broken'],
['mailto:user', 'user']
]
for (const [uri, recipient] of cases) {
const parsed = fastURI.parse(uri)
t.equal(parsed.error, 'URI mailto has an invalid recipient domain.', 'error for ' + uri)
t.deepEqual(parsed.to, [recipient], 'recipient is preserved for ' + uri)
}
t.end()
})
test('Mailto parsing handles domain literals, empty queries, and malformed headers', (t) => {
const literal = fastURI.parse('mailto:user@[IPv6:2001:db8::1]')
t.equal(literal.error, undefined, 'valid domain literal has no error')
t.deepEqual(literal.to, ['user@[ipv6:2001:db8::1]'], 'domain literal is preserved')
const emptyQuery = fastURI.parse('mailto:user@example.org?')
t.equal(emptyQuery.query, undefined, 'empty query is cleared')
t.equal(emptyQuery.headers, undefined, 'empty query does not create a header')
const malformedHeaders = fastURI.parse('mailto:joe@example.com?cc=bob@example.com?body=hello')
t.equal(malformedHeaders.error, 'URI mailto has malformed header fields.', 'malformed fields set an error')
t.deepEqual(malformedHeaders.to, ['joe@example.com'], 'path recipient is retained')
t.end()
})
test('Mailto parsing folds lone surrogates in raw input', (t) => {
// The handler sets `skipNormalize`, so it must fold lone surrogates itself --
// the generic query/path normalizers no longer run to do it.
const lone = '\uD800'
const trailing = '\uDC00'
t.deepEqual(
// spread: `headers` has a null prototype, which t.deepEqual compares strictly
{ ...fastURI.parse('mailto:a@b.test?x=' + lone).headers },
{ x: '�' },
'lone high surrogate in a header value becomes U+FFFD'
)
t.equal(
fastURI.parse('mailto:a@b.test?subject=' + trailing).subject,
'�',
'lone low surrogate in a subject becomes U+FFFD'
)
t.deepEqual(
fastURI.parse('mailto:' + lone + '@b.test').to,
['�@b.test'],
'lone surrogate in a local part becomes U+FFFD'
)
t.deepEqual(
fastURI.parse('mailto:a@b.test?subject=😀').subject,
'😀',
'a valid surrogate pair is left intact'
)
t.end()
})
test('Mailto domain fast path agrees with the WHATWG parser', (t) => {
// `mailtoNormalizeDomain` skips `new URL` when `nonSimpleMailtoDomain` is
// false. Every domain here must come out the same either way -- especially the
// all-numeric last labels, which `new URL` reads as IPv4 shorthand.
const domains = [
'example.com',
'example.org',
'mail2.example.org',
's3.amazonaws.com',
'ex4mple.com',
'_dmarc.example.com',
'-a.com',
'a..b',
'a.com.',
'1.2.3',
'127.1',
'12',
'0x7f.1',
'9.9.9.9',
'1.2.3.4'
]
for (const domain of domains) {
const parsed = fastURI.parse('mailto:user@' + domain)
let expected
try {
const url = new URL('http://' + domain)
const invalid = url.username || url.password || url.port ||
url.pathname !== '/' || url.search || url.hash || !url.hostname
expected = invalid ? domain : url.hostname
} catch {
expected = domain
}
t.deepEqual(parsed.to, ['user@' + expected], 'matches new URL for ' + domain)
}
// The fast path must not be taken for a domain whose last label is numeric.
t.deepEqual(fastURI.parse('mailto:user@1.2.3').to, ['user@1.2.0.3'], 'IPv4 shorthand is still applied')
t.deepEqual(fastURI.parse('mailto:user@mail2.example.org').to, ['user@mail2.example.org'], 'digits stay on the fast path')
t.end()
})
test('Mailto headers use a null prototype', (t) => {
// Header names come from untrusted input, so a lookup must not resolve to an
// inherited member of Object.prototype.
const parsed = fastURI.parse('mailto:a@b.test?blat=foop')
t.equal(Object.getPrototypeOf(parsed.headers), null, 'headers has no prototype')
t.equal(parsed.headers.toString, undefined, 'inherited members do not leak')
t.equal(parsed.headers.constructor, undefined, 'constructor does not leak')
// With a null prototype "__proto__" is an ordinary key rather than a setter,
// so it round-trips instead of being silently discarded.
const polluted = fastURI.parse('mailto:a@b.test?__proto__=x')
t.deepEqual(Object.keys(polluted.headers), ['__proto__'], '__proto__ is kept as an own key')
t.equal(fastURI.serialize(polluted), 'mailto:a@b.test?__proto__=x', '__proto__ round-trips')
t.equal({}.x, undefined, 'Object.prototype is not polluted')
t.equal(Object.getPrototypeOf({}), Object.prototype, 'Object.prototype is intact')
t.end()
})

@@ -40,1 +40,102 @@ 'use strict'

})
test('serialize preserves literal RFC 3986 reserved path characters', (t) => {
// GHSA-7mh8-fcmq-x23c: literal reserved path chars must not be rewritten to
// percent escapes by the escape()-style safe set.
const cases = [
'http://example.com/a;b',
'http://example.com/a=b',
'http://example.com/a&b',
'http://example.com/a$b',
'http://example.com/a:b',
'http://example.com/a@b'
]
t.plan(cases.length)
cases.forEach((uri) => {
t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri)
})
t.end()
})
test('serialize preserves existing reserved path escapes as data', (t) => {
// GHSA-7mh8-fcmq-x23c: an existing %3A escape must stay %3A instead of being
// rewritten into a live colon.
const cases = [
'http://example.com/a%3Ab',
'http://example.com/a%3Bb',
'http://example.com/a%3D%3D'
]
t.plan(cases.length)
cases.forEach((uri) => {
t.equal(fastURI.serialize(fastURI.parse(uri)), uri, uri)
})
t.end()
})
test('serialize keeps path-noscheme colon escaping', (t) => {
// Without a scheme a literal colon would be reparsed as a scheme separator,
// so it must stay percent-escaped while an existing %3A is preserved.
t.equal(fastURI.serialize({ path: 'foo:bar' }), 'foo%3Abar')
t.equal(fastURI.serialize({ path: 'a%3Ab' }), 'a%3Ab')
t.end()
})
test('hostname normalization never decodes an escape more than once', (t) => {
const encodedLocalhost = 'http://%256c%256f%2563%2561%256c%2568%256f%2573%2574/'
const encodedLoopback = '//127%252e0%252e0%252e1/private'
const encodedMetadataAddress = '//169%252E254%252E169%252E254/latest/meta-data/'
t.equal(fastURI.normalize(encodedLocalhost), encodedLocalhost, 'nested hostname letters remain encoded')
t.equal(fastURI.normalize(encodedLoopback), encodedLoopback, 'nested IPv4 dots remain encoded')
t.equal(
fastURI.resolve('https://safe.example/', encodedLoopback),
'https://127%252e0%252e0%252e1/private',
'resolve does not turn nested dots into a loopback address'
)
t.equal(
fastURI.resolve('https://allowed.com/api/v1/', encodedMetadataAddress),
'https://169%252e254%252e169%252e254/latest/meta-data/',
'resolve does not turn nested dots into a metadata address'
)
t.equal(
fastURI.normalize('http://allowed.com%255Cevil.com/'),
'http://allowed.com%255Cevil.com/',
'normalize does not activate a nested backslash'
)
t.equal(
fastURI.serialize({ scheme: 'http', host: '%256cocalhost', path: '/' }),
'http://%256cocalhost/',
'component serialization preserves an encoded percent sign'
)
t.equal(
fastURI.equal(encodedLocalhost, 'http://localhost/', {}),
false,
'nested escapes do not compare equal to their twice-decoded target'
)
t.end()
})
test('hostname normalization decodes only current unreserved escapes', (t) => {
t.equal(fastURI.normalize('x://%6cocalhost/'), 'x://localhost/', 'a current unreserved escape is decoded')
t.equal(fastURI.normalize('x://%256cocalhost/'), 'x://%256cocalhost/', 'an encoded percent is preserved')
t.equal(fastURI.normalize('x://host%2540evil/'), 'x://host%2540evil/', 'a nested authority delimiter stays inert')
t.equal(fastURI.normalize('x://%2525/'), 'x://%2525/', 'nested encoded percent signs stay encoded')
t.end()
})
test('host conversion failures are not treated as comparable URLs', (t) => {
const malformedHost = 'http://trusted.test%2540evil.test/'
t.equal(fastURI.normalize(malformedHost), malformedHost, 'normalization preserves the failing input')
t.equal(fastURI.equal(malformedHost, malformedHost, {}), false, 'equal rejects a failed host conversion')
t.throws(
() => fastURI.resolve(malformedHost, 'child', { domainHost: true }),
/Host's domain name can not be converted to ASCII/,
'resolve propagates a host conversion failure'
)
t.end()
})

@@ -174,1 +174,38 @@ 'use strict'

})
test('mailto serialization emits valid UTF-8 for supplementary characters', (t) => {
const component = {
scheme: 'mailto',
to: ['😀@example.org'],
subject: '😀',
body: 'go 🚀',
headers: { x: '😀' }
}
const uri = fastURI.serialize(component)
t.equal(
uri,
'mailto:%F0%9F%98%80@example.org?x=%F0%9F%98%80&subject=%F0%9F%98%80&body=go%20%F0%9F%9A%80'
)
t.doesNotThrow(() => decodeURIComponent(uri), 'serialized mailto URI can be decoded')
const parsed = fastURI.parse(uri)
t.deepEqual(parsed.to, ['😀@example.org'], 'local part round-trips')
t.equal(parsed.subject, '😀', 'subject round-trips')
t.equal(parsed.body, 'go 🚀', 'body round-trips')
// spread: `headers` has a null prototype, which t.deepEqual compares strictly
t.deepEqual({ ...parsed.headers }, { x: '😀' }, 'custom header round-trips')
t.end()
})
test('mailto serialization replaces lone surrogates with U+FFFD', (t) => {
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@example.org'], subject: '\uD800' }),
'mailto:user@example.org?subject=%EF%BF%BD',
'lone high surrogate'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@example.org'], body: '\uDC00' }),
'mailto:user@example.org?body=%EF%BF%BD',
'lone low surrogate'
)
t.end()
})

@@ -128,7 +128,9 @@ 'use strict'

test('normalize does not double-decode %2540 into a live @', (t) => {
const result = fastURI.normalize('http://trusted.com%2540evil.com/')
const input = 'http://trusted.com%2540evil.com/'
const result = fastURI.normalize(input)
const parsed = fastURI.parse(result)
t.plan(1)
t.notEqual(parsed.host, 'trusted.com@evil.com', 'http://trusted.com%2540evil.com/')
t.plan(2)
t.equal(result, input, 'the encoded percent sign is preserved')
t.notEqual(parsed.host, 'trusted.com@evil.com', input)
})

@@ -164,2 +166,72 @@

test('resolve canonicalises the host using the final resolved scheme', (t) => {
const cases = [
{
base: 'http://trusted.example/base',
relative: '//127\u30020\u30020\u30021/private',
expected: 'http://127.0.0.1/private',
expectedHost: '127.0.0.1',
description: 'scheme-relative loopback host'
},
{
base: 'https://ex\u00ADample.com/base',
relative: 'child',
expected: 'https://example.com/child',
expectedHost: 'example.com',
description: 'host inherited from the base'
},
{
base: 'http://trusted.example/base',
relative: 'http://ex\u200Bample.com/',
expected: 'http://example.com/',
expectedHost: 'example.com',
description: 'absolute relative reference'
}
]
t.plan(cases.length * 2)
cases.forEach(({ base, relative, expected, expectedHost, description }) => {
const resolved = fastURI.resolve(base, relative)
t.equal(resolved, expected, description)
t.equal(fastURI.parse(resolved).host, expectedHost, `${description} reparses consistently`)
})
})
test('resolve applies domain canonicalisation only when the effective scheme opts in', (t) => {
const host = 'ex\u00ADample.com'
t.plan(2)
t.equal(
fastURI.resolve('uri://trusted.example/', `//${host}/`),
`uri://${host}/`,
'an unsupported scheme preserves the host'
)
t.equal(
fastURI.resolve('http://trusted.example/', `//${host}/`, { unicodeSupport: true }),
`http://${host}/`,
'unicodeSupport preserves the Unicode host'
)
})
test('resolve throws when the final scheme cannot canonicalise the host', (t) => {
const invalidHost = '\u200D.example'
const cases = [
['http://trusted.example/', `//${invalidHost}/`],
[`https://${invalidHost}/base`, 'child'],
['http://trusted.example/', `http://${invalidHost}/`],
['http://trusted.example/', `//${invalidHost}%2Etest/`]
]
t.plan(cases.length)
cases.forEach(([base, relative]) => {
t.throws(
() => fastURI.resolve(base, relative),
/Host's domain name can not be converted to ASCII/,
`${base} + ${relative}`
)
})
})
test('parse rejects a literal backslash in the authority as malformed (RFC 3986)', (t) => {

@@ -166,0 +238,0 @@ // Regression for the host-confusion bypass: a literal "\" is invalid RFC 3986

@@ -152,1 +152,145 @@ 'use strict'

})
test('Mailto serialization preserves delimiters, escapes, and input state', (t) => {
const valueComponent = {
scheme: 'mailto',
to: ['user@example.org'],
headers: { x: 'a&b' }
}
const valueURI = fastURI.serialize(valueComponent)
t.equal(valueURI, 'mailto:user@example.org?x=a%26b', 'ampersand in header value is encoded')
// spread: `headers` has a null prototype, which t.deepEqual compares strictly
t.deepEqual({ ...fastURI.parse(valueURI).headers }, { x: 'a&b' }, 'header value round-trips')
const nameComponent = {
scheme: 'mailto',
to: ['user@example.org'],
headers: { 'x&y': 'z' }
}
const nameURI = fastURI.serialize(nameComponent)
t.equal(nameURI, 'mailto:user@example.org?x%26y=z', 'ampersand in header name is encoded')
t.deepEqual({ ...fastURI.parse(nameURI).headers }, { 'x&y': 'z' }, 'header name round-trips')
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@example.org'], subject: 'a%2fb' }),
'mailto:user@example.org?subject=a%2Fb',
'valid percent escape is preserved and uppercased'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@example.org'], subject: 'a%GGb' }),
'mailto:user@example.org?subject=a%25GGb',
'malformed percent escape is encoded'
)
const immutableComponent = {
scheme: 'mailto',
to: ['café@EXAMPLE.ORG'],
subject: 'hello',
headers: { cc: 'other@example.org' }
}
const expected = JSON.parse(JSON.stringify(immutableComponent))
fastURI.serialize(immutableComponent)
t.deepEqual(immutableComponent, expected, 'recipient and header containers are not mutated')
t.end()
})
test('Mailto serialization does not let recipient data inject URI structure', (t) => {
// A recipient domain must never reach the output with a delimiter intact:
// "?" would inject header fields and "#" a fragment.
const cases = [
['user@example.org?subject=Injected', 'mailto:user@example.org%3Fsubject%3Dinjected'],
['user@example.org#frag', 'mailto:user@example.org%23frag'],
// domain literals accept the full RFC 5321 dcontent range, which includes "?"
['user@[x?subject=Injected]', 'mailto:user@[x%3Fsubject%3Dinjected]'],
// "," is the recipient delimiter, so it must be encoded inside an address
['a,b@example.org', 'mailto:a%2Cb@example.org']
]
for (const [recipient, expected] of cases) {
const uri = fastURI.serialize({ scheme: 'mailto', to: [recipient] })
t.equal(uri, expected, 'encodes delimiters in ' + recipient)
const reparsed = fastURI.parse(uri)
t.deepEqual(reparsed.to, [recipient.toLowerCase()], 'round-trips ' + recipient)
t.equal(reparsed.subject, undefined, 'no subject injected by ' + recipient)
t.equal(reparsed.headers, undefined, 'no headers injected by ' + recipient)
t.equal(reparsed.fragment, undefined, 'no fragment injected by ' + recipient)
}
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['a@x.test', 'b@y.test'] }),
'mailto:a@x.test,b@y.test',
'the joining comma between recipients stays literal'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@[IPv6:2001:db8::1]'] }),
'mailto:user@[ipv6:2001:db8::1]',
'a valid domain literal keeps its delimiters'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@納豆.example.org'] }),
'mailto:user@xn--99zt52a.example.org',
'Unicode domain is still converted to ASCII'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['user@納豆.example.org'] }, { unicodeSupport: true }),
'mailto:user@納豆.example.org',
'Unicode domain is still preserved when requested'
)
t.end()
})
test('Mailto encoding is consistent across the fast/slow path boundary', (t) => {
// The encoder returns the input untouched when every character is allowed,
// and only then falls back to the per-character loop. Both branches must
// agree on what needs escaping.
const base = { scheme: 'mailto', to: ['user@example.org'] }
t.equal(
fastURI.serialize({ ...base, headers: { 'In-Reply-To': 'plain-value' } }),
'mailto:user@example.org?In-Reply-To=plain-value',
'fully allowed name and value are emitted verbatim'
)
t.equal(
fastURI.serialize({ ...base, headers: { 'x y': '<a>&b c' } }),
'mailto:user@example.org?x%20y=%3Ca%3E%26b%20c',
'disallowed characters are escaped in both name and value'
)
t.equal(
fastURI.serialize({ ...base, body: 'send current-issue\r\nsend index' }),
'mailto:user@example.org?body=send%20current-issue%0D%0Asend%20index',
'space and CRLF are escaped in a body'
)
t.equal(
fastURI.serialize({ ...base, subject: 'a%2fb%GGc' }),
'mailto:user@example.org?subject=a%2Fb%25GGc',
'valid escapes are uppercased and invalid ones escaped in one pass'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['a.b-c_d~e!f@example.org'] }),
'mailto:a.b-c_d~e!f@example.org',
'an entirely allowed local part takes the fast path unchanged'
)
t.end()
})
test('Mailto serialization ignores a caller-supplied path', (t) => {
// `to` is the only recipient source. A raw path has not been through this
// handler's encoder, and the handler sets `skipEscape`, so emitting it would
// put unescaped data (including CR/LF) straight into the URI.
t.equal(
fastURI.serialize({ scheme: 'mailto', path: 'a@b.test\r\nBcc: evil@evil.test' }),
'mailto:',
'a path containing CRLF is dropped, not emitted raw'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', path: 'user@example.org?subject=Injected' }),
'mailto:',
'a path containing header fields is dropped'
)
t.equal(
fastURI.serialize({ scheme: 'mailto', to: ['a@b.test'], path: 'ignored?x=1' }),
'mailto:a@b.test',
'a path is ignored when recipients are present'
)
t.end()
})

@@ -16,5 +16,2 @@ 'use strict'

}
if (value.slice(0, 6) === 'mailto') {
return t.skip('Skipping mailto schema test as it is not supported by fastifyURI')
}
t.same(JSON.parse(JSON.stringify(fastURI.parse(value))), expected, 'Compatibility parse: ' + value)

@@ -21,0 +18,0 @@ })

@@ -695,3 +695,3 @@ 'use strict'

t.deepEqual(components.to, ['list@example.org'], 'to')
t.deepEqual(components.headers, { 'In-Reply-To': '<3469A91.D10AF4C@example.com>' }, 'headers')
t.deepEqual({ ...components.headers }, { 'In-Reply-To': '<3469A91.D10AF4C@example.com>' }, 'headers')

@@ -705,3 +705,3 @@ components = fastURI.parse('mailto:majordomo@example.com?body=subscribe%20bamboo-l')

t.equal(components.body, 'hello', 'body')
t.deepEqual(components.headers, { cc: 'bob@example.com' }, 'headers')
t.deepEqual({ ...components.headers }, { cc: 'bob@example.com' }, 'headers')

@@ -716,3 +716,3 @@ components = fastURI.parse('mailto:joe@example.com?cc=bob@example.com?body=hello')

t.deepEqual(components.to, ['unlikely?address@example.com'], 'to unlikely?address@example.com')
t.deepEqual(components.headers, { blat: 'foop' }, 'headers')
t.deepEqual({ ...components.headers }, { blat: 'foop' }, 'headers')

@@ -719,0 +719,0 @@ components = fastURI.parse('mailto:Mike%26family@example.org')

@@ -19,2 +19,6 @@ type FastUri = typeof fastUri

error?: string;
to?: string[];
subject?: string;
body?: string;
headers?: { [hfname: string]: string };
}

@@ -21,0 +25,0 @@ export interface Options {

@@ -16,1 +16,17 @@ import uri, {

expect(parsed2).type.toBe<URIComponent>()
const mailtoComponent: URIComponent = {
scheme: 'mailto',
to: ['user@example.org'],
subject: 'Hello',
body: 'Message',
headers: { cc: 'other@example.org' }
}
expect(uri.serialize(mailtoComponent, { unicodeSupport: true })).type.toBe<string>()
const parsedMailto = uri.parse('mailto:user@example.org')
expect(parsedMailto.to).type.toBe<string[] | undefined>()
expect(parsedMailto.subject).type.toBe<string | undefined>()
expect(parsedMailto.body).type.toBe<string | undefined>()
expect(parsedMailto.headers).type.toBe<{ [hfname: string]: string } | undefined>()