base64.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { assertEmptyRest } from './assert.js'
  2. import { assertU8, fromUint8, fromBuffer, E_STRING } from './fallback/_utils.js'
  3. import { isHermes } from './fallback/platform.js'
  4. import { decodeLatin1, encodeLatin1 } from './fallback/latin1.js'
  5. import * as js from './fallback/base64.js'
  6. // See https://datatracker.ietf.org/doc/html/rfc4648
  7. // base64: A-Za-z0-9+/ and = if padding not disabled
  8. // base64url: A-Za-z0-9_- and = if padding enabled
  9. const { Buffer, atob, btoa } = globalThis // Buffer is optional, only used when native
  10. const haveNativeBuffer = Buffer && !Buffer.TYPED_ARRAY_SUPPORT
  11. const { toBase64: web64 } = Uint8Array.prototype // Modern engines have this
  12. const { E_CHAR, E_PADDING, E_LENGTH, E_LAST } = js
  13. // faster only on Hermes (and a little in old Chrome), js path beats it on normal engines
  14. const shouldUseBtoa = btoa && isHermes
  15. const shouldUseAtob = atob && isHermes
  16. // For native Buffer codepaths only
  17. const isBuffer = (x) => x.constructor === Buffer && Buffer.isBuffer(x)
  18. const toBuffer = (x) => (isBuffer(x) ? x : Buffer.from(x.buffer, x.byteOffset, x.byteLength))
  19. function maybeUnpad(res, padding) {
  20. if (padding) return res
  21. const at = res.indexOf('=', res.length - 3)
  22. return at === -1 ? res : res.slice(0, at)
  23. }
  24. function maybePad(res, padding) {
  25. return padding && res.length % 4 !== 0 ? res + '='.repeat(4 - (res.length % 4)) : res
  26. }
  27. const toUrl = (x) => x.replaceAll('+', '-').replaceAll('/', '_')
  28. const haveWeb = (x) => web64 && x.toBase64 === web64
  29. export function toBase64(x, { padding = true } = {}) {
  30. assertU8(x)
  31. if (haveWeb(x)) return padding ? x.toBase64() : x.toBase64({ omitPadding: !padding }) // Modern, optionless is slightly faster
  32. if (haveNativeBuffer) return maybeUnpad(toBuffer(x).base64Slice(0, x.byteLength), padding) // Older Node.js
  33. if (shouldUseBtoa) return maybeUnpad(btoa(decodeLatin1(x)), padding)
  34. return js.toBase64(x, false, padding) // Fallback
  35. }
  36. // NOTE: base64url omits padding by default
  37. export function toBase64url(x, { padding = false } = {}) {
  38. assertU8(x)
  39. if (haveWeb(x)) return x.toBase64({ alphabet: 'base64url', omitPadding: !padding }) // Modern
  40. if (haveNativeBuffer) return maybePad(toBuffer(x).base64urlSlice(0, x.byteLength), padding) // Older Node.js
  41. if (shouldUseBtoa) return maybeUnpad(toUrl(btoa(decodeLatin1(x))), padding)
  42. return js.toBase64(x, true, padding) // Fallback
  43. }
  44. // Unlike Buffer.from(), throws on invalid input (non-base64 symbols and incomplete chunks)
  45. // Unlike Buffer.from() and Uint8Array.fromBase64(), does not allow spaces
  46. // NOTE: Always operates in strict mode for last chunk
  47. // By default accepts both padded and non-padded variants, only strict base64
  48. export function fromBase64(str, options) {
  49. if (typeof options === 'string') options = { format: options } // Compat due to usage, TODO: remove
  50. if (!options) return fromBase64common(str, false, 'both', 'uint8', null)
  51. const { format = 'uint8', padding = 'both', ...rest } = options
  52. return fromBase64common(str, false, padding, format, rest)
  53. }
  54. // By default accepts only non-padded strict base64url
  55. export function fromBase64url(str, options) {
  56. if (!options) return fromBase64common(str, true, false, 'uint8', null)
  57. const { format = 'uint8', padding = false, ...rest } = options
  58. return fromBase64common(str, true, padding, format, rest)
  59. }
  60. // By default accepts both padded and non-padded variants, base64 or base64url
  61. export function fromBase64any(str, { format = 'uint8', padding = 'both', ...rest } = {}) {
  62. if (typeof str !== 'string') throw new TypeError(E_STRING)
  63. const isBase64url = !str.includes('+') && !str.includes('/') // likely to fail fast, as most input is non-url, also double scan is faster than regex
  64. return fromBase64common(str, isBase64url, padding, format, rest)
  65. }
  66. function fromBase64common(str, isBase64url, padding, format, rest) {
  67. if (typeof str !== 'string') throw new TypeError(E_STRING)
  68. if (rest !== null) assertEmptyRest(rest)
  69. const auto = padding === 'both' ? str.endsWith('=') : undefined
  70. // Older JSC supporting Uint8Array.fromBase64 lacks proper checks
  71. if (padding === true || auto === true) {
  72. if (str.length % 4 !== 0) throw new SyntaxError(E_PADDING) // JSC misses this
  73. if (str[str.length - 3] === '=') throw new SyntaxError(E_PADDING) // no more than two = at the end
  74. } else if (padding === false || auto === false) {
  75. if (str.length % 4 === 1) throw new SyntaxError(E_LENGTH) // JSC misses this in fromBase64
  76. if (padding === false && str.endsWith('=')) {
  77. throw new SyntaxError('Did not expect padding in base64 input') // inclusion is checked separately
  78. }
  79. } else {
  80. throw new TypeError('Invalid padding option')
  81. }
  82. return fromBase64impl(str, isBase64url, padding, format)
  83. }
  84. // ASCII whitespace is U+0009 TAB, U+000A LF, U+000C FF, U+000D CR, or U+0020 SPACE
  85. const ASCII_WHITESPACE = /[\t\n\f\r ]/ // non-u for JSC perf
  86. function noWhitespaceSeen(str, arr) {
  87. const at = str.indexOf('=', str.length - 3)
  88. const paddingLength = at >= 0 ? str.length - at : 0
  89. const chars = str.length - paddingLength
  90. const e = chars % 4 // extra chars past blocks of 4
  91. const b = arr.length - ((chars - e) / 4) * 3 // remaining bytes not covered by full blocks of chars
  92. return (e === 0 && b === 0) || (e === 2 && b === 1) || (e === 3 && b === 2)
  93. }
  94. let fromBase64impl
  95. if (Uint8Array.fromBase64) {
  96. // NOTICE: this is actually slower than our JS impl in older JavaScriptCore and (slightly) in SpiderMonkey, but faster on V8 and new JavaScriptCore
  97. fromBase64impl = (str, isBase64url, padding, format) => {
  98. const alphabet = isBase64url ? 'base64url' : 'base64'
  99. let arr
  100. if (padding === true) {
  101. // Padding is required from user, and we already checked that string length is divisible by 4
  102. // Padding might still be wrong due to whitespace, but in that case native impl throws expected error
  103. arr = Uint8Array.fromBase64(str, { alphabet, lastChunkHandling: 'strict' })
  104. } else {
  105. try {
  106. const padded = str.length % 4 > 0 ? `${str}${'='.repeat(4 - (str.length % 4))}` : str
  107. arr = Uint8Array.fromBase64(padded, { alphabet, lastChunkHandling: 'strict' })
  108. } catch (err) {
  109. // Normalize error: whitespace in input could have caused added padding to be invalid
  110. // But reporting that as a padding error would be confusing
  111. throw ASCII_WHITESPACE.test(str) ? new SyntaxError(E_CHAR) : err
  112. }
  113. }
  114. // We don't allow whitespace in input, but that can be rechecked based on output length
  115. // All other chars are checked natively
  116. if (!noWhitespaceSeen(str, arr)) throw new SyntaxError(E_CHAR)
  117. return fromUint8(arr, format)
  118. }
  119. } else if (haveNativeBuffer) {
  120. fromBase64impl = (str, isBase64url, padding, format) => {
  121. const size = Buffer.byteLength(str, 'base64')
  122. const arr = Buffer.allocUnsafeSlow(size) // non-pooled
  123. if (arr.base64Write(str) !== size) throw new SyntaxError(E_PADDING) // safe against junk after the end
  124. // Rechecking by re-encoding is cheaper than regexes on Node.js
  125. const valid = isBase64url ? arr.base64urlSlice(0, arr.length) : arr.base64Slice(0, arr.length)
  126. if (str !== valid) {
  127. if (padding === !isBase64url) throw new SyntaxError(E_PADDING) // should match native form
  128. if (maybePad(str, true) !== maybePad(valid, isBase64url)) throw new SyntaxError(E_PADDING)
  129. }
  130. return fromBuffer(arr, format) // fully checked
  131. }
  132. } else if (shouldUseAtob) {
  133. // atob is faster than manual parsing on Hermes
  134. fromBase64impl = (str, isBase64url, padding, format) => {
  135. let arr
  136. if (isBase64url) {
  137. if (/[\t\n\f\r +/]/.test(str)) throw new SyntaxError(E_CHAR) // atob verifies other invalid input
  138. str = str.replaceAll('-', '+').replaceAll('_', '/') // from url to normal
  139. }
  140. try {
  141. arr = encodeLatin1(atob(str))
  142. } catch {
  143. throw new SyntaxError(E_CHAR) // convert atob errors
  144. }
  145. if (!isBase64url && !noWhitespaceSeen(str, arr)) throw new SyntaxError(E_CHAR) // base64url checks input above
  146. if (arr.length % 3 !== 0) {
  147. // Check last chunk to be strict if it was incomplete
  148. const expected = toBase64(arr.subarray(-(arr.length % 3))) // str is normalized to non-url already
  149. const end = str.length % 4 === 0 ? str.slice(-4) : str.slice(-(str.length % 4)).padEnd(4, '=')
  150. if (expected !== end) throw new SyntaxError(E_LAST)
  151. }
  152. return fromUint8(arr, format)
  153. }
  154. } else {
  155. fromBase64impl = (str, isBase64url, padding, format) =>
  156. fromUint8(js.fromBase64(str, isBase64url), format) // validated in js
  157. }