Le sfumature delle stringhe di codifica Base64 in JavaScript

La codifica e decodifica in Base64 è una forma comune di trasformazione dei contenuti binari da rappresentare come testo sicuro per il web. Viene utilizzato comunemente per gli URL di dati, ad esempio le immagini incorporate.

Cosa succede quando applichi la codifica e decodifica Base64 alle stringhe in JavaScript? Questo post esplora le sfumature e gli errori più comuni da evitare.

btoa() e atob()

Le funzioni principali per la codifica e la decodifica in 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 esempio rapido:

// 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 indicato dai documenti MDN, questo funziona solo con stringhe che contengono caratteri ASCII o caratteri che possono essere rappresentati da un singolo byte. In altre parole, 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 nell'informatica che in JavaScript.

Stringhe in Unicode e JavaScript

Unicode è l'attuale standard globale 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 un approfondimento su Unicode, consulta questo articolo di W3C.

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

  • h - 104
  • ñ - 241
  • ❤ - 2764
  • ❤️ - 2764 con un modificatore nascosto numerato 65039
  • ⛳ - 9971
  • 🎃 - 129472

I numeri che rappresentano ciascun carattere sono chiamati "punti di codice". I "punti di codice" possono essere paragonati a un indirizzo a ogni carattere. Nell'emoji del cuore rosso ci sono due punti di codice: uno per un cuore e l'altro per "variare" il colore e renderlo sempre rosso.

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

Una visualizzazione eccessivamente semplificata è la seguente:

  • 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 interrompe funzioni come btoa(), che operano efficacemente sul presupposto che ogni carattere della stringa sia mappato a un singolo byte. Questo è indicato esplicitamente nell'MDN:

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

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

btoa() e atob() con Unicode

Come saprai, l'errore che viene generato è dovuto alla nostra stringa che contiene caratteri che si trovano al di fuori di un singolo byte in UTF-16.

Fortunatamente, l'articolo MDN su base64 include alcuni codici di esempio utili 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 che cosa fa questo codice per codificare la stringa:

  1. Utilizza l'interfaccia TextEncoder per prendere la stringa JavaScript con codifica UTF-16 e convertirla in un flusso di byte con codifica UTF-8 utilizzando TextEncoder.encode().
  2. Viene restituito un valore Uint8Array, che è un tipo di dati meno utilizzato in JavaScript e che è una sottoclasse di TypedArray.
  3. Prendi quel Uint8Array e forniscilo alla funzione bytesToBase64(), che utilizza String.fromCodePoint() per trattare ogni byte in Uint8Array come un punto di codice e creare una stringa da questo punto, che genera una stringa di punti di codice che possono essere tutti rappresentati come un singolo byte.
  4. Prendi quella stringa e utilizza btoa() per codificarla in base64.

Il processo di decodifica è la stessa cosa, ma al contrario.

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

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

Caso di errore silenzioso

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 ne controlli il valore esadecimale, scoprirai che è \uFFFD anziché il valore \uDE75 originale. L'operazione non riesce o restituisce un errore, ma i dati di input e di output vengono modificati in modo invisibile. 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 come esempio l'emoji del formaggio. L'emoji (yaml) ha un punto di codice Unicode di 129472. Sfortunatamente, il valore massimo per un numero a 16 bit è 65535. In che modo la codifica UTF-16 rappresenta questo numero molto più elevato?

La codifica UTF-16 ha un concetto chiamato coppie surogate. Ecco come:

  • Il primo numero della coppia specifica in quale "libro" effettuare la ricerca. Questo è chiamato "surrogato".
  • Il secondo numero della coppia è la voce nel "libro".

Come puoi immaginare, a volte potrebbe essere problematico avere solo il numero che rappresenta il libro, ma non la voce effettiva del libro. In UTF-16, è noto come surrogato solitario.

Questo è particolarmente impegnativo in JavaScript perché alcune API funzionano anche se hanno surrogati soli e altre non riescono.

In questo caso, stai utilizzando 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 decoder sostituisce i dati non corretti con un carattere sostitutivo.

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

Esistono vari standard web (ad esempio 1, 2, 3, 4) che specificano esattamente quando una stringa errata influisce sul comportamento dell'API, ma in particolare TextDecoder è una di queste API. È buona norma verificare che le stringhe abbiano un formato corretto prima di elaborare il testo.

Verifica la presenza di stringhe ben formattate

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

Supporto dei browser

  • 111
  • 111
  • 119
  • 16.4

Fonte

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

La seguente funzione utilizza isWellFormed() se è disponibile e encodeURIComponent() se non è disponibile. Puoi utilizzare 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 i surrogati Unicode sia i surrogati soli, puoi combinare tutto per creare codice che gestisca tutti i casi senza dover sostituire il testo in modalità silenziosa.

// 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 generalizzare in un polyfill, modificare i parametri TextDecoder in modo da generare invece la sostituzione silenziosa dei surrogati soli e altro ancora.

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

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