Le sfumature delle stringhe di codifica Base64 in JavaScript

La codifica e la decodifica base64 è una forma comune di trasformazione dei contenuti binari in modo che vengano rappresentati come testo sicuro per il web. Viene utilizzato comunemente per gli URL di dati, come le immagini incorporate.

Cosa succede quando applichi la codifica e la decodifica base64 alle stringhe in JavaScript? Questo post illustra le sfumature e gli errori comuni da evitare.

btoa() e atob()

Le funzioni principali per la codifica e decodifica Base64 in JavaScript sono btoa() e atob(). btoa() passa da una stringa a una stringa con codifica base64 e atob() esegue la decodifica.

Di seguito è riportato un breve esempio:

// A really plain string that is just code points below 128.
const asciiString = 'hello';

// This will work. It will print:
// Encoded string: [aGVsbG8=]
const asciiStringEncoded = btoa(asciiString);
console.log(`Encoded string: [${asciiStringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello]
const asciiStringDecoded = atob(asciiStringEncoded);
console.log(`Decoded string: [${asciiStringDecoded}]`);

Purtroppo, come notato nella documentazione MDN, questo funziona solo con stringhe che contengono caratteri ASCII o caratteri che possono essere rappresentati da un singolo byte. In altre parole, questo non funziona con Unicode.

Per vedere cosa succede, prova il seguente codice:

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will not work. It will print:
// DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.
try {
  const validUTF16StringEncoded = btoa(validUTF16String);
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);
} catch (error) {
  console.log(error);
}

Qualsiasi emoji nella stringa causerà un errore. Perché Unicode causa questo problema?

Per capire, facciamo un passo indietro e comprendiamo le stringhe, sia in computer science che in JavaScript.

Stringhe in Unicode e JavaScript

Unicode è lo standard globale attuale per la codifica dei caratteri o la pratica di assegnare un numero a un carattere specifico in modo che possa essere utilizzato nei sistemi informatici. Per informazioni più dettagliate su Unicode, consulta questo articolo di W3C.

Alcuni esempi di caratteri in Unicode e dei relativi numeri associati:

  • H-104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 con un modificatore nascosto numero 65039
  • ⛳ - 9971
  • 🔒 - 129.472

I numeri che rappresentano ogni carattere sono chiamati "punti di codice". Puoi pensare ai "punti di codice" come a un indirizzo per ogni carattere. Nell'emoji del cuore rosso esistono effettivamente due punti di codice: uno per un cuore e uno per "variare" il colore e farlo diventare sempre rosso.

Unicode ha due modi comuni per trasformare questi punti di codice in sequenze di byte che i computer possono interpretare in modo coerente: UTF-8 e UTF-16.

Ecco una visione semplificata:

  • In UTF-8, un punto di codice può utilizzare da uno a quattro byte (8 bit per byte).
  • In UTF-16, un punto di codice è sempre di due byte (16 bit).

È importante sottolineare che JavaScript elabora le stringhe come UTF-16. Questo rompe funzioni comebtoa(), che operano efficacemente presupponendo che ogni carattere della stringa sia mappato a un singolo byte. Ciò è specificato esplicitamente su MDN:

Il metodo btoa() crea una stringa ASCII con codifica Base64 da una stringa binaria (ovvero una stringa in cui ogni carattere è trattato come un byte di dati binari).

Ora che sai che i caratteri in JavaScript spesso richiedono più di un byte, la sezione successiva mostra come gestire questo caso per la codifica e la decodifica base64.

btoa() e atob() con Unicode

Come saprai, l'errore si verifica perché la stringa contiene caratteri che non rientrano in un singolo byte in UTF-16.

Fortunatamente, l'articolo MDN su base64 include del codice di esempio utile per risolvere questo "problema Unicode". Puoi modificare questo codice in modo che funzioni con l'esempio precedente:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is valid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
const validUTF16String = 'hello⛳❤️🧀';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
console.log(`Encoded string: [${validUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀]
const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
console.log(`Decoded string: [${validUTF16StringDecoded}]`);

I passaggi seguenti spiegano cosa fa questo codice per codificare la stringa:

  1. Utilizza l'interfaccia TextEncoder per prendere la stringa JavaScript codificata in UTF-16 e convertirla in uno stream di byte codificati in UTF-8 utilizzando TextEncoder.encode().
  2. Viene restituito un Uint8Array, che è un tipo di dati meno comunemente utilizzato in JavaScript ed è una sottoclasse del TypedArray.
  3. Prendi Uint8Array e forniscilo alla funzione bytesToBase64(), che utilizza String.fromCodePoint() per trattare ogni byte in Uint8Array come punto di codice e creare una stringa da cui si ottiene una stringa di punti di codice che possono essere tutti rappresentati come un singolo byte.
  4. Prendi la stringa e utilizza btoa() per codificarla in Base64.

La procedura di decodifica è la stessa, ma al contrario.

Questo funziona perché il passaggio tra Uint8Array e una stringa garantisce che, mentre la stringa in JavaScript è rappresentata come una codifica UTF-16 a due byte, il punto di codice rappresentato da ogni due byte sia sempre inferiore a 128.

Questo codice funziona bene nella maggior parte dei casi, ma non in altri.

Richiesta di errore silenziosa

Utilizza lo stesso codice, ma con una stringa diversa:

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Sample string that represents a combination of small, medium, and large code points.
// This sample string is invalid UTF-16.
// 'hello' has code points that are each below 128.
// '⛳' is a single 16-bit code units.
// '❤️' is a two 16-bit code units, U+2764 and U+FE0F (a heart and a variant).
// '🧀' is a 32-bit code point (U+1F9C0), which can also be represented as the surrogate pair of two 16-bit code units '\ud83e\uddc0'.
// '\uDE75' is code unit that is one half of a surrogate pair.
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

// This will work. It will print:
// Encoded string: [aGVsbG/im7PinaTvuI/wn6eA77+9]
const partiallyInvalidUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(partiallyInvalidUTF16String));
console.log(`Encoded string: [${partiallyInvalidUTF16StringEncoded}]`);

// This will work. It will print:
// Decoded string: [hello⛳❤️🧀�]
const partiallyInvalidUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(partiallyInvalidUTF16StringEncoded));
console.log(`Decoded string: [${partiallyInvalidUTF16StringDecoded}]`);

Se prendi l'ultimo carattere dopo la decodifica ( ) e controlli il suo valore esadecimale, scopri che si tratta di \uFFFD anziché dell'originale \uDE75. Non esegue un errore né genera un errore, ma i dati di input e output vengono modificati automaticamente. Perché?

Le stringhe variano in base all'API JavaScript

Come descritto in precedenza, JavaScript elabora le stringhe come UTF-16. Tuttavia, le stringhe UTF-16 hanno una proprietà unica.

Prendiamo ad esempio l'emoji del formaggio. L'emoji (🧀) ha un punto di codice Unicode di 129472. Purtroppo, il valore massimo per un numero a 16 bit è 65535. Quindi, come fa UTF-16 a rappresentare questo numero molto più elevato?

UTF-16 ha un concetto chiamato coppie di surrogati. Puoi pensarla in questo modo:

  • Il primo numero della coppia specifica il "libro" in cui cercare. Questo viene chiamato "sostituto".
  • Il secondo numero della coppia è la voce nel "libro".

Come puoi immaginare, a volte può essere problematico avere solo il numero che rappresenta il libro, ma non la voce effettiva all'interno del libro. In UTF-16, questo è conosciuto come sostituto solitario.

Questo è particolarmente difficile in JavaScript, perché alcune API funzionano nonostante abbiano sostituti singoli, mentre altre non funzionano.

In questo caso, utilizzi TextDecoder per la decodifica da base64. In particolare, i valori predefiniti per TextDecoder specificano quanto segue:

Il valore predefinito è false, il che significa che il decodificatore sostituisce i dati con formato non valido con un carattere di sostituzione.

Il carattere � che hai osservato in precedenza, rappresentato come \uFFFD in esadecimale, è il carattere sostitutivo. In UTF-16, le stringhe con surrogati soli sono considerate "non formattate correttamente" o "non ben formattate".

Esistono vari standard web (esempi 1, 2, 3, 4) che specificano esattamente quando una stringa con formato non corretto influisce sul comportamento dell'API, ma in particolare TextDecoder è una di quelle API. È buona norma assicurarsi che le stringhe siano formattate correttamente prima di eseguire l'elaborazione del testo.

Verifica la presenza di stringhe ben formattate

I browser molto recenti ora hanno una funzione per questo scopo: isWellFormed().

Supporto dei browser

  • Chrome: 111.
  • Edge: 111.
  • Firefox: 119.
  • Safari: 16.4.

Origine

Puoi ottenere un risultato simile utilizzando encodeURIComponent(), che genera un errore URIError se la stringa contiene un solo surrogato.

La funzione seguente utilizza isWellFormed() se è disponibile e encodeURIComponent() se non lo è. È possibile utilizzare un codice simile per creare un polyfill per isWellFormed().

// Quick polyfill since older browsers do not support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

Riassumendo

Ora che sai come gestire sia Unicode che i surrogati singoli, puoi mettere tutto insieme per creare un codice che gestisca tutti i casi e lo faccia senza la sostituzione silenziosa del testo.

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

// From https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem.
function bytesToBase64(bytes) {
  const binString = String.fromCodePoint(...bytes);
  return btoa(binString);
}

// Quick polyfill since Firefox and Opera do not yet support isWellFormed().
// encodeURIComponent() throws an error for lone surrogates, which is essentially the same.
function isWellFormed(str) {
  if (typeof(str.isWellFormed)!="undefined") {
    // Use the newer isWellFormed() feature.
    return str.isWellFormed();
  } else {
    // Use the older encodeURIComponent().
    try {
      encodeURIComponent(str);
      return true;
    } catch (error) {
      return false;
    }
  }
}

const validUTF16String = 'hello⛳❤️🧀';
const partiallyInvalidUTF16String = 'hello⛳❤️🧀\uDE75';

if (isWellFormed(validUTF16String)) {
  // This will work. It will print:
  // Encoded string: [aGVsbG/im7PinaTvuI/wn6eA]
  const validUTF16StringEncoded = bytesToBase64(new TextEncoder().encode(validUTF16String));
  console.log(`Encoded string: [${validUTF16StringEncoded}]`);

  // This will work. It will print:
  // Decoded string: [hello⛳❤️🧀]
  const validUTF16StringDecoded = new TextDecoder().decode(base64ToBytes(validUTF16StringEncoded));
  console.log(`Decoded string: [${validUTF16StringDecoded}]`);
} else {
  // Not reached in this example.
}

if (isWellFormed(partiallyInvalidUTF16String)) {
  // Not reached in this example.
} else {
  // This is not a well-formed string, so we handle that case.
  console.log(`Cannot process a string with lone surrogates: [${partiallyInvalidUTF16String}]`);
}

È possibile apportare molte ottimizzazioni a questo codice, ad esempio generalizzarlo in un polyfill, modificare i parametri TextDecoder in modo che generino un errore anziché sostituire silenziosamente i sostituti solitari e altro ancora.

Grazie a queste conoscenze e a questo codice, puoi anche prendere decisioni esplicite su come gestire le stringhe con formato non corretto, ad esempio rifiutare i dati, abilitarne esplicitamente la sostituzione o generare un errore per un'analisi successiva.

Oltre a essere un esempio utile per la codifica e la decodifica base64, questo post fornisce un esempio del motivo per cui un'attenta elaborazione del testo è particolarmente importante, in particolare quando i dati di testo provengono da fonti esterne o generate dagli utenti.