From 52f60190b7a70ac96d05044774fc65d106874a4c Mon Sep 17 00:00:00 2001 From: Tal Borenstein Date: Tue, 4 Aug 2026 11:42:13 +0300 Subject: [PATCH] feat(filters): add binary-safe base64_decode_bytes Add a cross-platform filter that decodes Base64 into raw bytes without forcing the result through UTF-8. It returns a Buffer in Node.js and a Uint8Array in browsers, while preserving the existing text behavior of base64_decode. --- docs/source/filters/base64_decode_bytes.md | 21 +++++++++++++ docs/source/filters/overview.md | 2 +- src/build/base64-impl-browser.spec.ts | 14 +++++++++ src/build/base64-impl-browser.ts | 4 +++ src/filters/base64-impl.ts | 4 +++ src/filters/base64.ts | 14 ++++++++- test/integration/filters/base64.spec.ts | 35 ++++++++++++++++++++++ 7 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 docs/source/filters/base64_decode_bytes.md diff --git a/docs/source/filters/base64_decode_bytes.md b/docs/source/filters/base64_decode_bytes.md new file mode 100644 index 0000000000..dbf90cded3 --- /dev/null +++ b/docs/source/filters/base64_decode_bytes.md @@ -0,0 +1,21 @@ +--- +title: base64_decode_bytes +--- + +{% since %}v10.29.0{% endsince %} + +Decodes a Base64-formatted string into raw bytes without interpreting the +result as UTF-8 text. It returns a `Buffer` in Node.js and a `Uint8Array` in +browsers. + +Use `evalValue()` or `evalValueSync()` to preserve the binary return value. +Rendering the result directly into a template converts it to text. + +```javascript +const bytes = engine.evalValueSync( + '"iVBORw0KGgr//g==" | base64_decode_bytes' +) +``` + +This filter is useful for binary content such as images and PDFs. For textual +content, use [`base64_decode`](./base64_decode.html) instead. diff --git a/docs/source/filters/overview.md b/docs/source/filters/overview.md index 6761b74380..d5ed137b19 100644 --- a/docs/source/filters/overview.md +++ b/docs/source/filters/overview.md @@ -15,7 +15,7 @@ HTML/URI | `escape`, `escape_once`, `url_encode`, `url_decode`, `strip_html`, `n Array | `slice`, `map`, `sort`, `sort_natural`, `uniq`, `where`, `where_exp`, `group_by`, `group_by_exp`, `find`, `find_exp`, `first`, `last`, `join`, `reverse`, `concat`, `compact`, `size`, `push`, `pop`, `shift`, `unshift` Date | `date`, `date_to_xmlschema`, `date_to_rfc822`, `date_to_string`, `date_to_long_string` Misc | `default`, `json`, `jsonify`, `inspect`, `raw`, `to_integer` -Base64 | `base64_encode`, `base64_decode` +Base64 | `base64_encode`, `base64_decode`, `base64_decode_bytes` Crypto | `sha256`, `hmac_sha256` [shopify/liquid]: https://github.com/Shopify/liquid diff --git a/src/build/base64-impl-browser.spec.ts b/src/build/base64-impl-browser.spec.ts index 6078d0c3be..9f407044fd 100644 --- a/src/build/base64-impl-browser.spec.ts +++ b/src/build/base64-impl-browser.spec.ts @@ -76,6 +76,20 @@ describe('base64-impl/browser', function () { }) }) + describe('#base64DecodeBytes()', function () { + it('should decode Base64 to raw bytes without UTF-8 corruption', function () { + const result = base64.base64DecodeBytes('iVBORw0KGgr//g==') + + expect(result).toEqual( + new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe]) + ) + }) + + it('should decode an empty string to an empty Uint8Array', function () { + expect(base64.base64DecodeBytes('')).toEqual(new Uint8Array()) + }) + }) + describe('round-trip encoding/decoding', function () { it('should encode and decode back to original', function () { const original = 'Hello, World!' diff --git a/src/build/base64-impl-browser.ts b/src/build/base64-impl-browser.ts index 100cf9f21e..eb7180e87e 100644 --- a/src/build/base64-impl-browser.ts +++ b/src/build/base64-impl-browser.ts @@ -8,3 +8,7 @@ export function base64Decode (str: string): string { Uint8Array.from(atob(str), c => c.charCodeAt(0)) ) } + +export function base64DecodeBytes (str: string): Uint8Array { + return Uint8Array.from(atob(str), c => c.charCodeAt(0)) +} diff --git a/src/filters/base64-impl.ts b/src/filters/base64-impl.ts index d19a0eb87d..dcbc3c2440 100644 --- a/src/filters/base64-impl.ts +++ b/src/filters/base64-impl.ts @@ -5,3 +5,7 @@ export function base64Encode (str: string): string { export function base64Decode (str: string): string { return Buffer.from(str, 'base64').toString('utf8') } + +export function base64DecodeBytes (str: string): Uint8Array { + return Buffer.from(str, 'base64') +} diff --git a/src/filters/base64.ts b/src/filters/base64.ts index 29ef84e52c..694812e8be 100644 --- a/src/filters/base64.ts +++ b/src/filters/base64.ts @@ -6,7 +6,7 @@ import { FilterImpl } from '../template' import { stringify } from '../util' -import { base64Encode, base64Decode } from './base64-impl' +import { base64Encode, base64Decode, base64DecodeBytes } from './base64-impl' export function base64_encode (this: FilterImpl, value: string | Buffer): string { if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) { @@ -23,3 +23,15 @@ export function base64_decode (this: FilterImpl, value: string): string { this.context.memoryLimit.use(str.length) return base64Decode(str) } + +/** + * Decodes a Base64 string into raw bytes without interpreting them as UTF-8. + * + * Returns a Buffer in Node.js and a Uint8Array in browsers. + */ +export function base64_decode_bytes (this: FilterImpl, value: string): Uint8Array { + const str = stringify(value) + const bytes = base64DecodeBytes(str) + this.context.memoryLimit.use(bytes.byteLength) + return bytes +} diff --git a/test/integration/filters/base64.spec.ts b/test/integration/filters/base64.spec.ts index 67eab977ff..23a4937831 100644 --- a/test/integration/filters/base64.spec.ts +++ b/test/integration/filters/base64.spec.ts @@ -65,6 +65,41 @@ describe('filters/base64', function () { }) }) + describe('base64_decode_bytes', function () { + it('should decode Base64 to raw bytes without UTF-8 corruption', () => { + const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe]) + const result = liquid.evalValueSync('data | base64_decode_bytes', { + data: bytes.toString('base64') + }) + + expect(Buffer.isBuffer(result)).toBe(true) + expect(result).toEqual(bytes) + }) + + it('should preserve bytes that are invalid UTF-8', () => { + const bytes = Buffer.from([0x80, 0xff, 0xfe, 0x00, 0x01]) + const result = liquid.evalValueSync('data | base64_decode_bytes', { + data: bytes.toString('base64') + }) + + expect(result).toEqual(bytes) + }) + + it('should decode an empty string to an empty Buffer', () => { + const result = liquid.evalValueSync('data | base64_decode_bytes', { data: '' }) + + expect(result).toEqual(Buffer.alloc(0)) + }) + + it('should round-trip arbitrary bytes through decode and encode filters', () => { + const bytes = Buffer.from([0x00, 0x01, 0x80, 0xff, 0xfe, 0xfd]) + return test( + `{{ "${bytes.toString('base64')}" | base64_decode_bytes | base64_encode }}`, + bytes.toString('base64') + ) + }) + }) + describe('base64_encode with Buffer input', function () { it('should encode a Buffer to base64 without data corruption', async () => { const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe])