¿Por qué la deencoding de Python reemplaza más que los bytes no válidos de una cadena codificada?

Al intentar decodificar una página html utf-8 codificada no válida, se obtienen resultados diferentes en python, firefox y chrome.

El fragmento codificado no válido de la página de prueba se parece a 'PREFIX\xe3\xabSUFFIX'

 >>> fragment = 'PREFIX\xe3\xabSUFFIX' >>> fragment.decode('utf-8', 'strict') ... UnicodeDecodeError: 'utf8' codec can't decode bytes in position 6-8: invalid data 

ACTUALIZACIÓN : Esta pregunta concluyó en un informe de error al componente Unicode de Python. El problema se ha solucionado en Python 2.7.11 y 3.5.2.


Lo que sigue son las políticas de reemplazo utilizadas para manejar los errores de deencoding en Python, Firefox y Chrome. Observe en qué se diferencian, y especialmente en cómo Python Builtin elimina la S válida (más la secuencia de bytes no válida).

Pitón

El controlador de errores de replace incorporado replace el \xe3\xab más la S de SUFFIX por U + FFFD

 >>> fragment.decode('utf-8', 'replace') u'PREFIX\ufffdUFFIX' >>> print _ PREFIX UFFIX 

Navegadores

Para probar cómo los navegadores decodifican la secuencia de bytes no válida, usarán un script cgi:

 #!/usr/bin/env python print """\ Content-Type: text/plain; charset=utf-8 PREFIX\xe3\xabSUFFIX""" 

Firefox y los navegadores Chrome renderizados:

 PREFIX SUFFIX 

Por qué el controlador de errores str.decode para str.decode está eliminando la S de SUFFIX

(Se actualizó 1)

Según wikipedia UTF-8 (gracias mjv), los siguientes rangos de bytes se utilizan para indicar el inicio de una secuencia de bytes

  • 0xC2-0xDF: inicio de secuencia de 2 bytes
  • 0xE0-0xEF: inicio de secuencia de 3 bytes
  • 0xF0-0xF4: inicio de secuencia de 4 bytes

'PREFIX\xe3\abSUFFIX' fragmento de prueba 'PREFIX\xe3\abSUFFIX' tiene 0xE3 , indica al decodificador de Python que sigue una secuencia de 3 bytes, la secuencia se encuentra inválida y el decodificador de Python ignora toda la secuencia, incluyendo '\xabS' , y continúa después de que ignore cualquier posible secuencia comenzando en el medio.

Esto significa que para una secuencia codificada no válida como '\xF0SUFFIX' , decodificará u'\ufffdFIX' lugar de u'\ufffdSUFFIX' .

Ejemplo 1: Introducción de errores de análisis de DOM

 >>> '
\xf0
Price: $20
...
'.decode('utf-8', 'replace') u'
\ufffdv>Price: $20
...
' >>> print _
v>Price: $20
...

Ejemplo 2: Problemas de seguridad (también vea las consideraciones de seguridad de Unicode ):

 >>> '\xf0<!-- alert("hi!"); -->'.decode('utf-8', 'replace') u'\ufffd- alert("hi!"); -->' >>> print _  - alert("hi!"); --> 

Ejemplo 3: Eliminar información válida para una aplicación de raspado

 >>> '\xf0' + u'it\u2019s'.encode('utf-8') # "it's" '\xf0it\xe2\x80\x99s' >>> _.decode('utf-8', 'replace') u'\ufffd\ufffd\ufffds' >>> print _    s 

Usando un script cgi para renderizar esto en los navegadores:

 #!/usr/bin/env python print """\ Content-Type: text/plain; charset=utf-8 \xf0it\xe2\x80\x99s""" 

Prestados

  it's 

¿Hay alguna forma oficial recomendada para manejar los reemplazos de deencoding?

(Se actualizó 2)

En una revisión pública , el Comité Técnico de Unicode ha optado por la opción 2 de los siguientes candidatos:

  1. Reemplace toda la subsecuencia mal formada por una sola U + FFFD.
  2. Reemplace cada subparte máxima de la subsecuencia mal formada por una sola U + FFFD.
  3. Reemplace cada unidad de código de la subsecuencia mal formada por una sola U + FFFD.

La resolución de la UTC fue el 2008-08-29, fuente: http://www.unicode.org/review/resolved-pri-100.html

UTC Public Review 121 también incluye un bytestream no válido como ejemplo '\x61\xF1\x80\x80\xE1\x80\xC2\x62' , muestra los resultados de deencoding para cada opción.

  61 F1 80 80 E1 80 C2 62 1 U+0061 U+FFFD U+0062 2 U+0061 U+FFFD U+FFFD U+FFFD U+0062 3 U+0061 U+FFFD U+FFFD U+FFFD U+FFFD U+FFFD U+FFFD U+0062 

En Python simple los tres resultados son:

  1. u'a\ufffdb' muestra como a b
  2. u'a\ufffd\ufffd\ufffdb' muestra como a b
  3. u'a\ufffd\ufffd\ufffd\ufffd\ufffd\ufffdb' muestra como a b

Y aquí está lo que Python hace para el ejemplo inválido bytestream:

 >>> '\x61\xF1\x80\x80\xE1\x80\xC2\x62'.decode('utf-8', 'replace') u'a\ufffd\ufffd\ufffd' >>> print _ a    

Nuevamente, usando un script cgi para probar cómo los navegadores procesan los bytes codificados con errores:

 #!/usr/bin/env python print """\ Content-Type: text/plain; charset=utf-8 \x61\xF1\x80\x80\xE1\x80\xC2\x62""" 

Ambos, Chrome y Firefox renderizados:

 a   b 

Tenga en cuenta que los resultados procesados ​​de los navegadores coinciden con la opción 2 de la recomendación PR121

Si bien la opción 3 se ve fácilmente implementable en Python, las opciones 2 y 1 son un desafío.

 >>> replace_option3 = lambda exc: (u'\ufffd', exc.start+1) >>> codecs.register_error('replace_option3', replace_option3) >>> '\x61\xF1\x80\x80\xE1\x80\xC2\x62'.decode('utf-8', 'replace_option3') u'a\ufffd\ufffd\ufffd\ufffd\ufffd\ufffdb' >>> print _ a      b 

Usted sabe que su S es válida, con el beneficio de mirar hacia adelante y en retrospectiva 🙂 Supongamos que originalmente había una secuencia legal de UTF-8 de 3 bytes allí, y que el tercer byte se corrompió en la transmisión … con el cambio que usted menciona, se estaría quejando de que no se ha reemplazado una S espuria. No hay una forma “correcta” de hacerlo, sin el beneficio de los códigos de corrección de errores, o una bola de cristal o tamborine .

Actualizar

Como comentó @mjv, el problema UTC tiene que ver con la cantidad de U + FFFD que se debe incluir.

De hecho, Python no está utilizando ninguna de las 3 opciones de UTC.

Aquí está el único ejemplo de la UTC:

  61 F1 80 80 E1 80 C2 62 1 U+0061 U+FFFD U+0062 2 U+0061 U+FFFD U+FFFD U+FFFD U+0062 3 U+0061 U+FFFD U+FFFD U+FFFD U+FFFD U+FFFD U+FFFD U+0062 

Esto es lo que hace Python:

 >>> bad = '\x61\xf1\x80\x80\xe1\x80\xc2\x62cdef' >>> bad.decode('utf8', 'replace') u'a\ufffd\ufffd\ufffdcdef' >>> 

¿Por qué?

F1 debe iniciar una secuencia de 4 bytes, pero el E1 no es válido. Una mala secuencia, un reemplazo.
Comience de nuevo en el siguiente byte, el 3 ° 80. Bang, otro FFFD.
Comienza de nuevo en el C2, que introduce una secuencia de 2 bytes, pero el C2 62 no es válido, así que vuelve a golpear.

Es interesante que la UTC no mencionó lo que está haciendo Python (reiniciando después del número de bytes indicado por el carácter principal). Quizás esto esté realmente prohibido o en desuso en algún lugar del estándar Unicode. Se requiere más lectura. Mira este espacio.

Actualización 2 Houston, tenemos un problema .

=== Citado del Capítulo 3 de Unicode 5.2 ===

Restricciones en los procesos de conversión

El requisito de no interpretar ninguna secuencia de código mal formada en una cadena como caracteres (ver cláusula de conformidad C10) tiene importantes consecuencias para los procesos de conversión.

Tales procesos pueden, por ejemplo, interpretar secuencias de unidades de código UTF-8 como secuencias de caracteres Unicode. Si el convertidor encuentra una secuencia de unidades de código UTF-8 mal formada que comienza con un primer byte válido, pero que no continúa con bytes sucesores válidos (consulte la Tabla 3-7), no debe consumir los bytes sucesores como parte de subsecuencia mal formada siempre que los bytes sucesores mismos formen parte de una subsecuencia de unidad de código UTF-8 bien formada .

Si una implementación de un proceso de conversión de UTF-8 se detiene en el primer error encontrado, sin informar el final de cualquier subsecuencia de unidad de código UTF-8 mal formada, el requisito hace poca diferencia práctica. Sin embargo, el requisito introduce una restricción significativa si el convertidor de UTF-8 continúa más allá del punto de un error detectado, quizás sustituyendo uno o más caracteres de reemplazo U + FFFD por la subsecuencia de la unidad de código UTF-8 no interpretable e incorrectamente conformada. Por ejemplo, con la secuencia de unidad de código UTF-8 de entrada , tal proceso de conversión UTF-8 no debe devolver o , ya que cualquiera de esas salidas ser el resultado de una mala interpretación de una subsecuencia bien formada como parte de la subsecuencia mal formada. El valor de retorno esperado para dicho proceso sería, en cambio, .

Para que un proceso de conversión de UTF-8 consum bytes sucesores válidos no solo no es conforme , sino que también deja el convertidor abierto a ataques de seguridad . Consulte el Informe técnico de Unicode #36 , “Consideraciones de seguridad de Unicode”.

=== Fin de la cita ===

Luego continúa discutiendo en detalle, con ejemplos, el problema de “cuántos FFFD para emitir”.

Usando su ejemplo en el segundo párrafo citado:

 >>> bad2 = "\xc2\x41\x42" >>> bad2.decode('utf8', 'replace') u'\ufffdB' # FAIL 

Tenga en cuenta que este es un problema con las opciones 'replace' e 'ignore' de str.decode (‘utf_8’): se trata de omitir datos, no de cuántos U + FFFD se emiten; aclare la parte emisora ​​de datos y el problema de U + FFFD se resuelve de forma natural, como se explica en la parte que no mencioné.

Actualización 3 Las versiones actuales de Python (incluida la versión 2.7) tienen unicodedata.unidata_version como '5.1.0' que puede indicar o no que el código relacionado con Unicode está diseñado para cumplir con Unicode 5.1.0. En cualquier caso, la prohibición de lo que Python está haciendo no aparece en el estándar Unicode hasta el 5.2.0. Plantearé un problema en el rastreador de Python sin mencionar la palabra 'oht'.encode('rot13') .

Reportado aquí

El byte 0xE3 es uno (de los posibles) primeros bytes indicativos de un carácter de 3 bytes.

Al parecer, la lógica de deencoding de Python toma estos tres bytes y trata de decodificarlos. Resultan que no coinciden con un punto de código real (“carácter”) y es por eso que Python produce un error UnicodeDecode y emite un carácter de sustitución
Sin embargo, parece que al hacerlo, la lógica de deencoding de Python no se adhiere a la recomendación del Consorcio Unicode con respecto a los caracteres de sustitución para las secuencias UTF-8 “mal formadas”.

Consulte el artículo UTF-8 en Wikipedia para obtener información de fondo sobre la encoding UTF-8.

Nuevo (¿final?) Edición : consulte la práctica recomendada de UniCode Consortium para reemplazar caracteres (PR121)
(Por cierto, felicidades a Dangra por seguir cavando y cavando y por lo tanto mejorando la pregunta)
Tanto dangra como yo fuimos parcialmente incorrectos, a nuestra manera, con respecto a la interpretación de esta recomendación; mi última idea es que, de hecho, la recomendación también habla de intentar y “volver a sincronizar”.
El concepto clave es el de la subparte máxima [de una secuencia mal formada] .
En vista del (único) ejemplo proporcionado en el documento PR121, la “subparte máxima” implica no leer en los bytes que posiblemente no podrían formar parte de una secuencia. Por ejemplo, el quinto byte en la secuencia, 0xE1 NO podría ser un “segundo, tercer o cuarto byte de una secuencia” ya que no está en el rango de x80-xBF, y por lo tanto esto termina la secuencia mal formada que comenzó con xf1. Luego, se debe intentar iniciar una nueva secuencia con el xE1, etc. De manera similar, al golpear el x62 que tampoco puede interpretarse como un segundo / tercer / cuarto byte, la secuencia incorrecta finaliza y la “b” (x62) es ” salvado”…

En este sentido (y hasta que se corrija ;-)) la lógica de deencoding de Python parece estar defectuosa.

También vea la respuesta de John Machin en esta publicación para obtener citas más específicas del estándar / recomendaciones subyacentes de Unicode.

En 'PREFIX\xe3\xabSUFFIX' , el \xe3 indica que este y los dos \xe3 siguientes forman un punto de código Unicode. ( \xEy hace para todos y). Sin embargo, \xe3\xabS obviamente no se refiere a un punto de código válido. Como Python sabe que se supone que debe tomar tres bytes, aspira a los tres de todos modos ya que no sabe que su S es una S y no solo un byte que representa 0x53 por alguna otra razón.

Además, ¿existe alguna forma oficial recomendada por Unicode para manejar los reemplazos de deencoding?

No. Unicode los considera una condición de error y no considera ninguna opción alternativa. Así que ninguno de los comportamientos anteriores son “correctos”.