aboutsummaryrefslogtreecommitdiffstats
path: root/vanilla/node_modules/@exodus/bytes/base64.js
blob: e8625f7df19960b3afcaf0f763dd130324a86780 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
import { assertEmptyRest } from './assert.js'
import { typedView } from './array.js'
import { assertU8, E_STRING } from './fallback/_utils.js'
import { isHermes } from './fallback/platform.js'
import { decodeLatin1, encodeLatin1 } from './fallback/latin1.js'
import * as js from './fallback/base64.js'

// See https://datatracker.ietf.org/doc/html/rfc4648

// base64:    A-Za-z0-9+/ and = if padding not disabled
// base64url: A-Za-z0-9_- and = if padding enabled

const { Buffer, atob, btoa } = globalThis // Buffer is optional, only used when native
const haveNativeBuffer = Buffer && !Buffer.TYPED_ARRAY_SUPPORT
const { toBase64: web64 } = Uint8Array.prototype // Modern engines have this

const { E_CHAR, E_PADDING, E_LENGTH, E_LAST } = js

// faster only on Hermes (and a little in old Chrome), js path beats it on normal engines
const shouldUseBtoa = btoa && isHermes
const shouldUseAtob = atob && isHermes

// For native Buffer codepaths only
const isBuffer = (x) => x.constructor === Buffer && Buffer.isBuffer(x)
const toBuffer = (x) => (isBuffer(x) ? x : Buffer.from(x.buffer, x.byteOffset, x.byteLength))

function maybeUnpad(res, padding) {
  if (padding) return res
  const at = res.indexOf('=', res.length - 3)
  return at === -1 ? res : res.slice(0, at)
}

function maybePad(res, padding) {
  return padding && res.length % 4 !== 0 ? res + '='.repeat(4 - (res.length % 4)) : res
}

const toUrl = (x) => x.replaceAll('+', '-').replaceAll('/', '_')
const haveWeb = (x) => web64 && x.toBase64 === web64

export function toBase64(x, { padding = true } = {}) {
  assertU8(x)
  if (haveWeb(x)) return padding ? x.toBase64() : x.toBase64({ omitPadding: !padding }) // Modern, optionless is slightly faster
  if (haveNativeBuffer) return maybeUnpad(toBuffer(x).base64Slice(0, x.byteLength), padding) // Older Node.js
  if (shouldUseBtoa) return maybeUnpad(btoa(decodeLatin1(x)), padding)
  return js.toBase64(x, false, padding) // Fallback
}

// NOTE: base64url omits padding by default
export function toBase64url(x, { padding = false } = {}) {
  assertU8(x)
  if (haveWeb(x)) return x.toBase64({ alphabet: 'base64url', omitPadding: !padding }) // Modern
  if (haveNativeBuffer) return maybePad(toBuffer(x).base64urlSlice(0, x.byteLength), padding) // Older Node.js
  if (shouldUseBtoa) return maybeUnpad(toUrl(btoa(decodeLatin1(x))), padding)
  return js.toBase64(x, true, padding) // Fallback
}

// Unlike Buffer.from(), throws on invalid input (non-base64 symbols and incomplete chunks)
// Unlike Buffer.from() and Uint8Array.fromBase64(), does not allow spaces
// NOTE: Always operates in strict mode for last chunk

// By default accepts both padded and non-padded variants, only strict base64
export function fromBase64(str, options) {
  if (typeof options === 'string') options = { format: options } // Compat due to usage, TODO: remove
  if (!options) return fromBase64common(str, false, 'both', 'uint8', null)
  const { format = 'uint8', padding = 'both', ...rest } = options
  return fromBase64common(str, false, padding, format, rest)
}

// By default accepts only non-padded strict base64url
export function fromBase64url(str, options) {
  if (!options) return fromBase64common(str, true, false, 'uint8', null)
  const { format = 'uint8', padding = false, ...rest } = options
  return fromBase64common(str, true, padding, format, rest)
}

// By default accepts both padded and non-padded variants, base64 or base64url
export function fromBase64any(str, { format = 'uint8', padding = 'both', ...rest } = {}) {
  const isBase64url = !str.includes('+') && !str.includes('/') // likely to fail fast, as most input is non-url, also double scan is faster than regex
  return fromBase64common(str, isBase64url, padding, format, rest)
}

function fromBase64common(str, isBase64url, padding, format, rest) {
  if (typeof str !== 'string') throw new TypeError(E_STRING)
  if (rest !== null) assertEmptyRest(rest)
  const auto = padding === 'both' ? str.endsWith('=') : undefined
  // Older JSC supporting Uint8Array.fromBase64 lacks proper checks
  if (padding === true || auto === true) {
    if (str.length % 4 !== 0) throw new SyntaxError(E_PADDING) // JSC misses this
    if (str[str.length - 3] === '=') throw new SyntaxError(E_PADDING) // no more than two = at the end
  } else if (padding === false || auto === false) {
    if (str.length % 4 === 1) throw new SyntaxError(E_LENGTH) // JSC misses this in fromBase64
    if (padding === false && str.endsWith('=')) {
      throw new SyntaxError('Did not expect padding in base64 input') // inclusion is checked separately
    }
  } else {
    throw new TypeError('Invalid padding option')
  }

  return typedView(fromBase64impl(str, isBase64url, padding), format)
}

// ASCII whitespace is U+0009 TAB, U+000A LF, U+000C FF, U+000D CR, or U+0020 SPACE
const ASCII_WHITESPACE = /[\t\n\f\r ]/ // non-u for JSC perf

function noWhitespaceSeen(str, arr) {
  const at = str.indexOf('=', str.length - 3)
  const paddingLength = at >= 0 ? str.length - at : 0
  const chars = str.length - paddingLength
  const e = chars % 4 // extra chars past blocks of 4
  const b = arr.length - ((chars - e) / 4) * 3 // remaining bytes not covered by full blocks of chars
  return (e === 0 && b === 0) || (e === 2 && b === 1) || (e === 3 && b === 2)
}

let fromBase64impl
if (Uint8Array.fromBase64) {
  // NOTICE: this is actually slower than our JS impl in older JavaScriptCore and (slightly) in SpiderMonkey, but faster on V8 and new JavaScriptCore
  fromBase64impl = (str, isBase64url, padding) => {
    const alphabet = isBase64url ? 'base64url' : 'base64'

    let arr
    if (padding === true) {
      // Padding is required from user, and we already checked that string length is divisible by 4
      // Padding might still be wrong due to whitespace, but in that case native impl throws expected error
      arr = Uint8Array.fromBase64(str, { alphabet, lastChunkHandling: 'strict' })
    } else {
      try {
        const padded = str.length % 4 > 0 ? `${str}${'='.repeat(4 - (str.length % 4))}` : str
        arr = Uint8Array.fromBase64(padded, { alphabet, lastChunkHandling: 'strict' })
      } catch (err) {
        // Normalize error: whitespace in input could have caused added padding to be invalid
        // But reporting that as a padding error would be confusing
        throw ASCII_WHITESPACE.test(str) ? new SyntaxError(E_CHAR) : err
      }
    }

    // We don't allow whitespace in input, but that can be rechecked based on output length
    // All other chars are checked natively
    if (!noWhitespaceSeen(str, arr)) throw new SyntaxError(E_CHAR)
    return arr
  }
} else if (haveNativeBuffer) {
  fromBase64impl = (str, isBase64url, padding) => {
    const arr = Buffer.from(str, 'base64')
    // Rechecking by re-encoding is cheaper than regexes on Node.js
    const got = isBase64url ? maybeUnpad(str, padding === false) : maybePad(str, padding !== true)
    const valid = isBase64url ? arr.base64urlSlice(0, arr.length) : arr.base64Slice(0, arr.length)
    if (got !== valid) throw new SyntaxError(E_PADDING)
    return arr // fully checked
  }
} else if (shouldUseAtob) {
  // atob is faster than manual parsing on Hermes
  fromBase64impl = (str, isBase64url, padding) => {
    let arr
    if (isBase64url) {
      if (/[\t\n\f\r +/]/.test(str)) throw new SyntaxError(E_CHAR) // atob verifies other invalid input
      str = str.replaceAll('-', '+').replaceAll('_', '/') // from url to normal
    }

    try {
      arr = encodeLatin1(atob(str))
    } catch {
      throw new SyntaxError(E_CHAR) // convert atob errors
    }

    if (!isBase64url && !noWhitespaceSeen(str, arr)) throw new SyntaxError(E_CHAR) // base64url checks input above

    if (arr.length % 3 !== 0) {
      // Check last chunk to be strict if it was incomplete
      const expected = toBase64(arr.subarray(-(arr.length % 3))) // str is normalized to non-url already
      const end = str.length % 4 === 0 ? str.slice(-4) : str.slice(-(str.length % 4)).padEnd(4, '=')
      if (expected !== end) throw new SyntaxError(E_LAST)
    }

    return arr
  }
} else {
  fromBase64impl = (str, isBase64url, padding) => js.fromBase64(str, isBase64url) // validated in js
}