- Вопрос или проблема
- Итог
- Дополнительная информация
- lsfd (и большинство утилит util-linux): выводит необработанные данные:
- column: экранирует неоднозначно:
- journalctl: массив байтов:
- curl: бред
- cvtsudoers: необработанный
- dmesg: необработанный
- iproute2: необработанный и с ошибками
- exiftool: заменяет байты на ?
- lsar: интерпретирует байтовые значения, как если бы это были кодовые точки юникода для tar:
- lsipc: необработанный
- GNU parallel: необработанный
- rg: переключается с “text”:”…” на “bytes”:”base64…”
- sqlite3: необработанный
- tree: необработанный
- lslocks: необработанный
- @raf‘s rawhide: необработанный
- FreeBSD ps –libxo=json: экранирование:
- FreeBSD wc –libxo=json: необработанный
- Инструменты обработки JSON
- jsesc: принимает, но преобразует в U+FFFD
- jq: принимает, преобразует в U+FFFD, но неправильно:
- gojq: то же самое без ошибки
- json_pp: принимает, преобразует в U+FFFD
- json_xs: то же самое
- jshon: ошибка
- json5: принимает, преобразует в U+FFFD
- jc: ошибка
- mlr: принимает, преобразует в U+FFFD
- vd: ошибка
- JSON::Parse: ошибка
- jo: ошибка
- jsed: принимает и преобразует в U+FFFD
- Ответ или решение
- 1. Как называется формат JSON с недопустимыми символами UTF-8?
- 2. Существуют ли инструменты или модули программирования, которые надежно обрабатывают этот формат?
- 3. Как преобразовать неподходящий формат в валидный JSON и обратно?
- Общие рекомендации
- Заключение
Вопрос или проблема
На Unix-подобных системах наблюдается растущее количество утилит, которые выбирают формат JSON для обмена данными¹, несмотря на то, что строки JSON не могут напрямую представлять произвольные пути к файлам, имена процессов, аргументы командной строки и, более широко, строки C², которые могут содержать текст, закодированный в различных кодировках символов, или вовсе не предназначены для текстового отображения.
Например, многие утилиты util-linux, такие как Linux LVM, systemd
, curl
, GNU parallel
, ripgrep, sqlite3, tree и многие утилиты FreeBSD с их опцией --libxo=json
… могут выводить данные в формате JSON, которые затем, якобы, можно разобрать программно “надежно”.
Но если некоторые строки, которые они предназначены выводить (такие как имена файлов), содержат текст, не закодированный в UTF-8, это, похоже, все распадается.
Я вижу различное поведение среди утилит в этом случае:
- те, которые преобразуют байты, которые не могут быть декодированы, либо заменяя их символом замены, таким как
?
(например,exiftool
) или U+FFFD (�), либо используя некоторую форму кодирования, иногда в необратимом виде ("\\x80"
, например, вcolumn
) - те, которые переключаются на другое представление, например, из
"json-string"
в[65, 234]
— массив байтов вjournalctl
или из{"text":"foo"}
в{"bytes":"base64-encoded"}
вrg
. - те, которые обрабатывают это неправильно, например,
curl
- и подавляющее большинство, которые просто сбрасывают эти байты, которые не составляют действительный UTF-8, как есть, а именно с JSON-строками, содержащими недействительный UTF-8.
Большинство утилит util-linux
попадают в последнюю категорию. Например, с lsfd
:
$ sh -c 'lsfd -Joname -p "$$" --filter "(ASSOC == \"3\")"' 3> $'\x80' | sed -n l
{$
"lsfd": [$
{$
"name": "/home/chazelas/tmp/\200"$
}$
]$
}$
Это означает, что они выводят недействительный UTF-8, а следовательно, недействительный JSON.
Теперь, хотя это строго говоря недействительно, этот вывод все равно однозначен и теоретически может быть обработан³.
Тем не менее, я проверил множество утилит для обработки JSON, и ни одна из них не смогла его обработать. Они либо:
- выдают ошибку с ошибкой декодирования
- заменяют эти байты на U+FFFD
- проваливаются каким-либо жалким образом или другим
Мне кажется, что я что-то упускаю. Наверняка, когда этот формат был выбран, это должно было быть учтено?
Итог
Итак, мои вопросы:
- есть ли у этого формата JSON с неправильно закодированными строками UTF-8 (с некоторыми значениями байтов >= 0x80, которые не являются частью действительных символов, закодированных в UTF-8) название?
- Существуют ли инструменты или модули языков программирования (предпочтительно
perl
, но я открыт для других), которые могут надежно обрабатывать этот формат? - Или этот формат можно конвертировать в/из действительного JSON, чтобы его могли обрабатывать утилиты обработки JSON, такие как
jq
,json_xs
,mlr
… Предпочтительно так, чтобы действительные строки JSON были сохранены и информация не была потеряна?
Дополнительная информация
Ниже приведено состояние моих собственных исследований. Это всего лишь вспомогательные данные, которые вы можете найти полезными. Это просто быстрый дамп, команды написаны в синтаксисе zsh
и были выполнены на системе Debian unstable (и FreeBSD 12.4-RELEASE-p5 для некоторых). Извините за беспорядок.
lsfd (и большинство утилит util-linux): выводит необработанные данные:
$ sh -c 'lsfd -Joname -p "$$" --filter "(ASSOC == \"3\")"' 3> $'\x80' | sed -n l
{$
"lsfd": [$
{$
"name": "/home/chazelas/\200"$
}$
]$
}$
column: экранирует неоднозначно:
$ printf '%s\n' $'St\351phane' 'St\xe9phane' $'a\0b' | column -JC name=firstname
{
"table": [
{
"firstname": "St\\xe9phane"
},{
"firstname": "St\\xe9phane"
},{
"firstname": "a"
}
]
}
Переключение на локаль с использованием latin1 (или любой однобайтовой кодировке, охватывающей весь диапазон байтов) помогает получить необработанный формат:
$ printf '%s\n' $'St\351phane' $'St\ue9phane' | LC_ALL=C.iso88591 column -JC name=firstname | sed -n l
{$
"table": [$
{$
"firstname": "St\351phane"$
},{$
"firstname": "St\303\251phane"$
}$
]$
}$
journalctl: массив байтов:
$ logger $'St\xe9phane'
$ journalctl -r -o json | jq 'select(._COMM == "logger").MESSAGE'
[
83,
116,
233,
112,
104,
97,
110,
101
]
curl: бред
$ printf '%s\r\n' 'HTTP/1.0 200' $'Test: St\xe9phane' '' | socat -u - tcp-listen:8000,reuseaddr &
$ curl -w '%{header_json}' http://localhost:8000
{"test":["St\uffffffe9phane"]
}
Могло бы иметь смысл с \U
, если бы не то, что юникод теперь ограничен кодовыми точками до \U0010FFFF
только.
cvtsudoers: необработанный
$ printf 'Defaults secure_path="/home/St\351phane/bin"' | cvtsudoers -f json | sed -n l
{$
"Defaults": [$
{$
"Options": [$
{ "secure_path": "/home/St\351phane/bin" }$
]$
}$
]$
}$
dmesg: необработанный
$ printf 'St\351phane\n' | sudo tee /dev/kmsg
$ sudo dmesg -J | sed -n /phane/l
"msg": "St\351phane"$
iproute2: необработанный и с ошибками
По крайней мере для ip link
, даже управляющие символы 0x1 .. 0x1f (только некоторые из которых не допускаются в именах интерфейсов) выводятся необработанными, что недопустимо в JSON.
$ ifname=$'\1\xe9'
$ sudo ip link add name $ifname type dummy
$ sudo ip link add name $ifname type dummy
(добавлено дважды! Первый раз его переименовали в __
).
$ ip l
[...]
14: __: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 12:22:77:40:6f:8c brd ff:ff:ff:ff:ff:ff
15: �: <BROADCAST,NOARP> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 12:22:77:40:6f:8c brd ff:ff:ff:ff:ff:ff
$ ip -j l | sed -n l
[...]
dcast":"ff:ff:ff:ff:ff:ff"},{"ifindex":14,"ifname":"__","flags":["BRO\
ADCAST","NOARP"],"mtu":1500,"qdisc":"noop","operstate":"DOWN","linkmo\
de":"DEFAULT","group":"default","txqlen":1000,"link_type":"ether","ad\
dress":"12:22:77:40:6f:8c","broadcast":"ff:ff:ff:ff:ff:ff"},{"ifindex\
":15,"ifname":"\001\351","flags":["BROADCAST","NOARP"],"mtu":1500,"qd\
isc":"noop","operstate":"DOWN","linkmode":"DEFAULT","group":"default"\
,"txqlen":1000,"link_type":"ether","address":"12:22:77:40:6f:8c","bro\
adcast":"ff:ff:ff:ff:ff:ff"}]$
$ ip -V
ip utility, iproute2-6.5.0, libbpf 1.2.2
exiftool: заменяет байты на ?
$ exiftool -j $'St\xe9phane.txt'
[{
"SourceFile": "St?phane.txt",
"ExifToolVersion": 12.65,
"FileName": "St?phane.txt",
"Directory": ".",
"FileSize": "0 bytes",
"FileModifyDate": "2023:09:30 10:04:21+01:00",
"FileAccessDate": "2023:09:30 10:04:26+01:00",
"FileInodeChangeDate": "2023:09:30 10:04:21+01:00",
"FilePermissions": "-rw-r--r--",
"Error": "File is empty"
}]
lsar: интерпретирует байтовые значения, как если бы это были кодовые точки юникода для tar:
$ tar cf f.tar $'St\xe9phane.txt' $'St\ue9phane.txt'
$ lsar --json f.tar| grep FileNa
"XADFileName": "Stéphane.txt",
"XADFileName": "Stéphane.txt",
Для zip: кодирование URI
$ bsdtar --format=zip -cf a.zip St$'\351'phane.txt Stéphane.txt
$ lsar --json a.zip | grep FileNa
"XADFileName": "St%e9phane.txt",
"XADFileName": "Stéphane.txt",
lsipc: необработанный
$ ln -s /usr/lib/firefox-esr/firefox-esr $'St\xe9phane'
$ ./$'St\xe9phane' -new-instance
$ lsipc -mJ | grep -a phane | sed -n l
"command": "./St\351phane -new-instance"$
"command": "./St\351phane -new-instance"$
GNU parallel: необработанный
$ parallel --results -.json echo {} ::: $'\xe9' | sed -n l
{ "Seq": 1, "Host": ":", "Starttime": 1696068481.231, "JobRuntime": 0\
.001, "Send": 0, "Receive": 2, "Exitval": 0, "Signal": 0, "Command": \
"echo '\351'", "V": [ "\351" ], "Stdout": "\351\\u000a", "Stderr": ""\
}$
rg: переключается с “text”:”…” на “bytes”:”base64…”
$ echo $'St\ue9phane' | rg --json '.*'
{"type":"begin","data":{"path":{"text":"<stdin>"}}}
{"type":"match","data":{"path":{"text":"<stdin>"},"lines":{"text":"Stéphane\n"},"line_number":1,"absolute_offset":0,"submatches":[{"match":{"text":"Stéphane"},"start":0,"end":9}]}}
{"type":"end","data":{"path":{"text":"<stdin>"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":137546,"human":"0.000138s"},"searches":1,"searches_with_match":1,"bytes_searched":10,"bytes_printed":235,"matched_lines":1,"matches":1}}}
{"data":{"elapsed_total":{"human":"0.002445s","nanos":2445402,"secs":0},"stats":{"bytes_printed":235,"bytes_searched":10,"elapsed":{"human":"0.000138s","nanos":137546,"secs":0},"matched_lines":1,"matches":1,"searches":1,"searches_with_match":1}},"type":"summary"}
$ echo $'St\xe9phane' | LC_ALL=C rg --json '.*'
{"type":"begin","data":{"path":{"text":"<stdin>"}}}
{"type":"match","data":{"path":{"text":"<stdin>"},"lines":{"bytes":"U3TpcGhhbmUK"},"line_number":1,"absolute_offset":0,"submatches":[{"match":{"text":"St"},"start":0,"end":2},{"match":{"text":"phane"},"start":3,"end":8}]}}
{"type":"end","data":{"path":{"text":"<stdin>"},"binary_offset":null,"stats":{"elapsed":{"secs":0,"nanos":121361,"human":"0.000121s"},"searches":1,"searches_with_match":1,"bytes_searched":9,"bytes_printed":275,"matched_lines":1,"matches":2}}}
{"data":{"elapsed_total":{"human":"0.002471s","nanos":2471435,"secs":0},"stats":{"bytes_printed":275,"bytes_searched":9,"elapsed":{"human":"0.000121s","nanos":121361,"secs":0},"matched_lines":1,"matches":2,"searches":1,"searches_with_match":1}},"type":"summary"}
Интересное кодирование “x-user-defined”:
$ echo $'St\xe9\xeaphane' | rg -E x-user-defined --json '.*' | jq -a .data.lines.text
null
"St\uf7e9\uf7eaphane\n"
null
null
С символами в области зарезервированного использования для не-ASCII текста. https://www.w3.org/International/docs/encoding/#x-user-defined
sqlite3: необработанный
$ sqlite3 -json a.sqlite3 'select * from a' | sed -n l
[{"a":"a"},$
{"a":"\351"}]$
tree: необработанный
$ tree -J | sed -n l
[$
{"type":"directory","name":".","contents":[$
{"type":"file","name":"\355\240\200\355\260\200"},$
{"type":"file","name":"a.zip"},$
{"type":"file","name":"f.tar"},$
{"type":"file","name":"St\303\251phane.txt"},$
{"type":"link","name":"St\351phane","target":"/usr/lib/firefox-es\
r/firefox-esr"},$
{"type":"file","name":"St\351phane.txt"}$
]}$
,$
{"type":"report","directories":1,"files":6}$
]$
lslocks: необработанный
$ lslocks --json | sed -n /phane/l
"path": "/home/chazelas/1/St\351phane.txt"$
@raf‘s rawhide: необработанный
$ rh -j | sed -n l
[...]
{"path":"./St\351phane", "name":"St\351phane", "start":".", "depth":1\
[...]
FreeBSD ps –libxo=json: экранирование:
$ sh -c 'sleep 1000; exit' $'\xe9' &
$ ps --libxo=json -o args -p $!
{"process-information": {"process": [{"arguments":"sh -c sleep 1000; exit \\M-i"}]}
}
$ sh -c 'sleep 1000; exit' '\M-i' &
$ ps --libxo=json -o args -p $!
{"process-information": {"process": [{"arguments":"sh -c sleep 1000; exit \\\\M-i"}]}
}
FreeBSD wc –libxo=json: необработанный
$ wc --libxo=json $'\xe9' | LC_ALL=C sed -n l
{"wc": {"file": [{"lines":10,"words":10,"characters":21,"filename":"\351"}]}$
}$
Смотрите также этот отчет о ошибке о sesutil map --libxo
, где и репортер, и разработчики ожидают, что вывод должен быть UTF-8. А также то обсуждение, в котором вводился libxo, где вопрос кодировки обсуждался, но без реального заключения.
Инструменты обработки JSON
jsesc: принимает, но преобразует в U+FFFD
$ jsesc -j $'\xe9'
"\uFFFD"
jq: принимает, преобразует в U+FFFD, но неправильно:
$ print '"a\351b"' | jq -a .
"a\ufffd"
$ print '"a\351bc"' | jq -a .
"a\ufffdbc"
gojq: то же самое без ошибки
$ echo '"\xe9ab"' | gojq -j . | uconv -x hex
\uFFFD\u0061\u0062
json_pp: принимает, преобразует в U+FFFD
$ print '"a\351b"' | json_pp -json_opt ascii,pretty
"a\ufffdb"
json_xs: то же самое
$ print '"a\351b"' | json_xs | uconv -x hex
\u0022\u0061\uFFFD\u0062\u0022\u000A
То же самое с -e
:
$ print '"\351"' | PERL_UNICODE= json_xs -t none -e 'printf "%x\n", ord($_)'
fffd
jshon: ошибка
$ printf '{"file":"St\351phane"}' | jshon -e file -u
json read error: line 1 column 11: unable to decode byte 0xe9 near '"St'
json5: принимает, преобразует в U+FFFD
$ echo '"\xe9"' | json5 | uconv -x hex
\u0022\uFFFD\u0022
jc: ошибка
$ echo 'St\xe9phane' | jc --ls
jc: Error - ls parser could not parse the input data.
If this is the correct parser, try setting the locale to C (LC_ALL=C).
For details use the -d or -dd option. Use "jc -h --ls" for help.
mlr: принимает, преобразует в U+FFFD
$ echo '{"f":"St\xe9phane"}' | mlr --json cat | sed -n l
[$
{$
"f": "St\357\277\275phane"$
}$
]$
vd: ошибка
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 1: invalid continuation byte
JSON::Parse: ошибка
$ echo '"\xe9"'| perl -MJSON::Parse=parse_json -l -0777 -ne 'print parse_json($_)'
JSON error at line 1, byte 3/4: Unexpected character '"' parsing string starting from byte 1: expecting bytes in range 80-bf: 'x80-\xbf' at -e line 1, <> chunk 1.
jo: ошибка
$ echo '\xe9' | jo -a
jo: json.c:1209: emit_string: Assertion `utf8_validate(str)' failed.
zsh: done echo '\xe9' |
zsh: IOT instruction jo -a
Можно использовать base64:
$ echo '\xe9' | jo a=@-
jo: json.c:1209: emit_string: Assertion `utf8_validate(str)' failed.
zsh: done echo '\xe9' |
zsh: IOT instruction jo a=@-
$ echo '\xe9' | jo a=%-
{"a":"6Qo="}
jsed: принимает и преобразует в U+FFFD
$ echo '{"a":"\xe9"}' | ./jsed get --path a | uconv -x hex
\uFFFD%
¹ См. zgrep -li json ${(s[:])^"$(man -w)"}/man[18]*/*(N)
для списка команд, которые могут обрабатывать JSON.
² И строки C не могут представлять произвольные строки JSON как строки C, в отличие от строк JSON, которые не могут содержать NUL.
³ Хотя обработка может стать проблематичной, так как конкатенация двух таких строк может в конечном итоге сформировать действительные символы и разорвать некоторые предположения.
Чем больше я исследую эту тему, тем больше я убеждаюсь, что поведение lsfd
и других аналогичных утилит неправильное; RFC 8259 8.1 гласит:
JSON-текст, обмениваемый между системами, которые не находятся в замкнутой экосистеме, ДОЛЖЕН быть закодирован с использованием UTF-8.
Факт того, что вы сталкиваетесь с этой проблемой, свидетельствует о том, что эти выходные данные не заключены в замкнутую экосистему, и, следовательно, JSON-текст нарушает RFC 8259.
С моей точки зрения, было бы полезно, если бы были открыты сообщения об ошибках для соответствующих проектов, чтобы уведомить их о проблеме. Затем задача состоит в том, чтобы поддерживающие проектами решили, как справиться с проблемой.
С точки зрения поддержки этого проекта, это должно быть разрешимо: lsfd
мог бы учитывать переменные окружения LC_CTYPE / LANG, предположить, что входные данные получены из этой локали, и преобразовать их в UTF-8.
имеет ли этот формат JSON с неправильно закодированными строками UTF-8 (с некоторыми значениями байтов >= 0x80, которые не являются частью действительных символов, закодированных в UTF-8) название?
Ответ: “Сломанный”
Я шучу, но только слегка. На самом деле то, что происходит здесь, заключается в том, что JSON записывается в UTF-8, но не выполняется проверка, чтобы убедиться, что все входные данные также являются UTF-8. Так что технически то, что вы видите, представляет собой смешение кодировок символов, а не json-файл, закодированный в нестандартной кодировке символов.
Существуют ли инструменты или модули языков программирования (предпочтительно perl, но я открыт для других), которые могут надежно обрабатывать этот формат?
Но давайте будем откровенны, то, о чем мы говорим, это обработка вывода, который предполагалось как UTF-8, но таковым не является. Таким образом, рабочие решения тут в основном работают благодаря удаче, а не проектированию! Это похоже на “неопределенное поведение”.
Для некоторых наборов символов могут существовать обходные пути. Чтобы эти обходные пути сработали, набор символов должен либо полностью отображать каждый байт в юникод, либо вы должны быть уверены, что немаппингованные байт-коды не будут использоваться на практике. Набор символов также должен совместно использовать однобайтовые коды символов с UTF-8, в частности символы для []{}:""''\
.
LATIN-1 — это единственный, который я знаю, что сработает, и это срабатывает исключительно потому, что у Юникода есть специальный блок, который называется дополнение LATIN-1. Это позволяет преобразовать LATIN-1 в юникод, просто копируя байтовое значение как кодовую точку юникода.
Однако подобный cp1252 имеет пробелы, которые не могут быть отображены в юникоде, и решения быстро выходят из строя.
Способ, которым я бы предложил справляться с таким сломленным поведением, заключался бы в том, чтобы работать с Python3, который четко понимает разницу между последовательностью байтов и строкой, предназначенной для представления текста.
Вы можете считывать необработанные байты в Python3, а затем декодировать их в строку, предполагая кодировку по вашему выбору:
import sys
import json
data = sys.stdin.buffer.read()
string_data = data.decode("LATIN1")
decoded_structure = json.loads(string_data)
Вы можете затем манипулировать JSON в основном с помощью операторов []
. Например, для JSON с латинским-1 Ç
:
{
"lsfd": [
{
"name": "/home/chazelas/tmp/Ç"
}
]
}
Вы можете распечатать имя с помощью:
import sys
import json
data = sys.stdin.buffer.read()
string_data = data.decode("LATIN1")
decoded_structure = json.loads(string_data)
print(decoded_structure["lsfd"]["name"].encode("LATIN1"))
Этот подход также позволяет вам обрабатывать данные как байты, прежде чем вы начнете рассматривать их как строку. Это полезно, когда все становится действительно запутанным, например, если входные данные должны быть закодированы в cp1252, но содержат недействительные байты для cp1252.
import sys
import json
data = sys.stdin.buffer.read()
data = data.replace(b'\x90', b'\\x90')
data = data.replace(b'\x9D', b'\\x9D')
string_data = data.decode("cp1252")
decoded_structure = json.loads(string_data)
print(decoded_structure["lsfd"]["name"].encode("cp1252"))
Возможный (не полностью удовлетворительный) подход, если не нужно учитывать какие-либо строки в JSON как текст, заключается в предварительной обработке входных данных для инструмента обработки JSON (jq
, mlr
…) с помощью iconv -f latin1 -t utf-8
и постобработке его вывода с помощью iconv -f utf-8 -t latin1
, то есть конвертировать все байты >= 0x80 в символ с соответствующей кодовой точкой юникода, или другими словами, рассматривать входные данные как если бы они были закодированы в latin1.
$ exec 3> $'\x80\xff'
$ ls -ld "$(lsfd -Jp "$$" | jq -r '.lsfd[]|select(.assoc=="3").name')"
ls: cannot access '/home/chazelas/1/��': No such file or directory
Не работает, потому что jq
преобразовал эти байты в U+FFFD, но:
$ ls -ld "$(lsfd -Jp "$$" | iconv -fl1 | jq -r '.lsfd[]|select(.assoc=="3").name' | iconv -tl1)"
-rw-r--r-- 1 chazelas chazelas 0 Sep 30 15:51 '/home/chazelas/tmp/'$'\200\377'
Работает. Теперь есть много способов, которыми это может распасться:
- длина строк в количестве байтов и символов изменяется в процессе, поэтому любая проверка длины, которую вы собираетесь сделать, вероятно, будет неточной (хотя длина в символах строк JSON будет соответствовать длине в байтах имени файла).
- вам нужно убедиться, что инструмент обработки JSON не экранирует символы как
\uxxxx
(не используйте, например,-a
вjq
), иначе символы не будут преобразованы обратно в байты впоследствии; - инструмент обработки JSON также не должен производить новые строки с символами с кодовыми точками >= 0x80; или если вы это сделаете, вам нужно сделать двойное кодирование. Например,
jq -r '"Fichier trouvé : " + .file'
вместоjq -r '"Fichier trouvé : " + .file'
, если вы хотите, чтобы они были закодированы в UTF-8 после прохождения черезiconv -f utf-8 -t latin1
. - Любая проверка или операция на основе текста, такая как проверка классов символов, сортировка и т. д., не будет действительна.
Использование кодировок x-user-defined
, которые могут быть использованы в HTML вместо latin1, позволило бы избежать некоторых из этих проблем, потому что все байты >= 0x80 были бы сопоставлены с непрерывными символами в области частного использования (так что их нельзя было бы ошибочно классифицировать как alpha/blank и они не включились бы в некоторые диапазоны [a-z]
/[0-9]
…); но насколько я вижу, ни одна из iconv
/uconv
/recode
не поддерживает эту кодировку.
Использование latin1 имеет то преимущество, что вы можете проверять значения байтов на основе кодовой точки. Например, чтобы найти открытые файлы, чье имя содержит байт 0x80:
$ lsfd -Jp "$$" | iconv -fl1 -tu8 | jq -r '.lsfd[]|select(.name|contains("\u0080"))' | iconv -fu8 -tl1
{
"command": "zsh",
"pid": 8127,
"user": "chazelas",
"assoc": "3",
"mode": "-w-",
"type": "REG",
"source": "0:38",
"mntid": 42,
"inode": 2501864,
"name": "/home/chazelas/tmp/��"
}
(��
— это то, как мой терминал UTF-8 отображает эти байты здесь; u8 и l1 являются сокращениями для UTF-8 и Latin1, также известного как ISO-8859-1 соответственно, они могут не поддерживаться всеми реализациями iconv
).
Вы могли бы определить вспомогательный скрипт binary
для ksh (или любой оболочки, поддерживающей скоро стандартную опцию pipefail
, которая, кроме dash
, поддерживается большинством оболочек), такой как:
#! /bin/ksh -
set -o pipefail
iconv -f latin1 -t utf-8 |
"$@" |
iconv -f utf-8 -t latin1
Затем можно использовать такие команды, как:
lsfd -J |
binary jq -j '
.lsfd[] |
select(
.assoc=="1" and
.type=="REG" and
(.name|match("[^\u0000-\u007f]"))
) | (.name + "\u0000")' |
LC_ALL=C sort -zu |
xargs -r0 ls -ldU --
Для получения списка обычных файлов, открытых на стандартный вывод любого процесса и путь к которым содержит байт с установленным 8-м битом (больше 0x7f / 127).
В том же духе, модуль JSON
для Perl (и его подлежащие реализации JSON::XS
и JSON::PP
), с его объектно-ориентированным интерфейсом, не выполняет декодирование/кодирование текста самостоятельно, он работает с уже декодированным текстом. По умолчанию, пока переменная окружения PERL_UNICODE
не установлена, входные/выходные данные декодируются/кодируются в latin1.
Утилиты, такие как json_xs
/json_pp
, которые используют эти модули в качестве инструментов командной строки, явно декодируют/кодируют в UTF-8, но если вы используете эти модули напрямую, вы можете пропустить этот шаг и работать в latin1:
$ exec 3> $'\x80\xff'
$ lsfd -Jp "$$" | perl -MJSON -l -0777 -ne '
$_ = JSON->new->decode($_);
print $_->{name} for grep {$_->{assoc} == 3} @{$_->{lsfd}}' |
sed -n l
/home/chazelas/tmp/\200\377$
У них даже есть явный флаг latin1
, похожий на флаг ascii
, чтобы убедиться, что JSON, который они производят, когда закодирован в latin1, может представлять символы вне диапазона U+0000 .. U+00FF, выражая их как \uxxxx
. Без этого флага эти символы в конечном итоге будут закодированы в UTF-8 с сообщением об ошибке.
Использование latin1 также делает относительно простым обработку представления сообщений journalctl
как [1, 2, 3]
, где нам просто нужно преобразовать эти байтовые значения в символ с соответствующей кодовой точкой юникода (и когда они закодированы как latin1, вы получаете правильный байт обратно)
Некоторые ограничения, упомянутые выше, также применимы здесь, это всего лишь эквивалент команд iconv
, который выполняется внутри perl
, или, точнее, мы переходим прямо от байта к символу с тем же значением без перехода через байт в UTF-8 и шаги с UTF-8 к символу.
$ logger $'St\xe9phane'
$ journalctl --since today -o json | perl -MJSON -lne '
BEGIN{$j = JSON->new}
$j->incr_parse($_);
while ($obj = $j->incr_parse) {
$msg = $obj->{MESSAGE};
# обработка массива целочисленного представления
$msg = join "", map(chr, @$msg) if ref $msg eq "ARRAY";
print $msg
}' |
sed -n '/phane/l'
St\351phane$
С этой точки зрения мы можем ответить на все вопросы:
-
Какое название у этого формата? Это JSON, закодированный в latin1 вместо закодированного в UTF-8, или любой другой однобайтовый набор символов, который является надмножеством ASCII и имеет отображение юникода, охватывающее весь диапазон байтов, который мы решили использовать (для интерпретации входных данных и производства выходных данных).
Преимущество этих наборов по сравнению с UTF-8 в том, что каждая последовательность байтов является действительным текстом в этих кодировках, поэтому их можно использовать для представления в виде текста любого имени файла Unix, аргумента команды, строки C, как это делают эти утилиты.
RFC JSON строго не запрещает использование кодировок, отличных от UTF-8, если это в пределах замкнутой экосистемы. Это было бы недействительно только для совместимости. Предыдущая версия RFC даже была более лояльной к этому. Если бы эти инструменты, производящие этот формат, надлежащим образом задокументировали это, это могло бы считаться не ошибкой.
-
Какой инструмент может обрабатывать этот формат? Любой, который может декодировать/кодировать JSON в произвольных наборах символов, а не только в UTF-8. Как видно выше, современные версии
jello
это делают. МодулиJSON
/JSON::XS
/JSON::PP
для perl явно поддерживают latin1. -
Как предварительно обработать этот формат, чтобы его могли обрабатывать обычные утилиты JSON? Предварительно обработайте входные данные, перекодировав их из Latin1 (или другого однобайтового набора символов) в UTF-8, а затем постобработайте вывод, перекодировав обратно.
В python3
(по крайней мере в версии 3.11.5, где я это тестирую) поведение схоже с поведением perl
и его модулей JSON. Входные/выходные данные декодируются/кодируются вне модуля python, и в данном случае в соответствии с кодировкой символов локали, хотя кодировка символов может быть переопределена с помощью переменной окружения PYTHONIOENCODING
.
Локали C и C.UTF-8 (в отличие от других локалей, использующих UTF-8 как кодировку символов) выглядят как специальный случай, в которых входные/выходные данные декодируются/кодируются в UTF-8 (хотя кодировка символов локали C неизменно ASCII), но байты, которые не составляют часть действительного UTF-8, декодируются с кодовыми точками в диапазоне 0xDC80 до 0xDCFF (эти кодовые точки попадают во вторую половину пар суррогатов UTF-16, так что это недействительные кодовые точки символов, что делает их безопасными для использования здесь).
То же самое можно достичь без изменения локали, установив
PYTHONIOENCODING=utf-8:surrogateescape
Тогда мы можем обрабатывать JSON, который в целом предназначен для кодировки в UTF-8, но который может содержать строки, не являющиеся UTF-8.
$ printf '"\xe9"' | PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json, sys; _ = json.load(sys.stdin); print(hex(ord(_)))'
0xdce9
0xe9 байт декодирован как символ 0xdce9.
$ printf '"\xe9"' | PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json, sys; _ = json.load(sys.stdin); print(_)' | od -An -vtx1
e9 0a
0xdce9 закодирован обратно в байт 0xe9 на выходе.
Пример обработки вывода lsfd
:
$ exec 3> $'\x80\xff'
$ lsfd -Jp "$$" | PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json, sys
_ = json.load(sys.stdin)
for e in _["lsfd"]:
if e["assoc"] == "3":
print(e["name"])' | sed -n l
/home/chazelas/tmp/\200\377$
Примечание: если вы хотите сгенерировать какой-либо JSON на выходе, вам нужно передать ensure_ascii=False
, иначе для байтов, которые не могут быть декодированы в UTF-8, вы получите:
$ printf '"\xe9"' | PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json, sys; _ = json.load(sys.stdin); print(json.dumps(_))'
"\udce9"
Который большинство вещей вне python
отклонят.
$ printf '"\xe9"' | PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json, sys
_ = json.load(sys.stdin)
print(json.dumps(_, ensure_ascii=False))' | sed -n l
"\351"$
Также, как было отмечено в вопросе, если у вас есть две строки JSON, которые являются результатом строки, закодированной в UTF-8, разрезанной посередине символа, конкатенация их в JSON не объединит эти последовательности байтов в символ, пока они не будут закодированы обратно в UTF-8:
$ printf '{"a":"St\xc3","b":"\xa9phane"}' | PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json, sys
_ = json.load(sys.stdin)
myname = _["a"] + _["b"]; print(len(myname), myname)'
9 Stéphane
Мое имя было успешно восстановлено на выходе, но обратите внимание, насколько длина некорректна, так как myname
содержит символы \udcc3
и \udca9
в эскейп-последовательностях, а не восстановленный символ \u00e9
.
Вы можете заставить это объединение происходить, пройдя через шаги encode
и decode
с использованием кодировки ввода-вывода:
$ printf '{"a":"St\xc3","b":"\xa9phane"}' |
PYTHONIOENCODING=utf-8:surrogateescape python3 -c '
import json,sys
_ = json.load(sys.stdin)
myname = (_["a"] + _["b"]).encode(sys.stdout.encoding,sys.stdout.errors).decode(sys.stdout.encoding,sys.stdout.errors)
print(len(myname), myname)'
8 Stéphane
В любом случае, также возможно кодировать/декодировать в latin1, как в perl
, чтобы значения символов соответствовали байтовым значениям в строках, вызвав его в локали, использующей эту кодировку, или используя PYTHONIOENCODING=latin1
.
vd
(visidata), хотя и написан в python3
, когда входные данные поступают от stdin, похоже, не учитывает $PYTHONIOENCODING
, и в C или C.UTF-8 локалях, похоже, это не выполняет суррогатное экранирование (см. эту проблему), но вызов с --encoding=latin1
с версией 2.5 или новее (где эта проблема была исправлена) или в локали, использующей кодировку latin1, похоже, работает, поэтому вы можете сделать:
lsfd -J | binary jq .lsfd | LC_CTYPE=C.iso88591 vd -f json
Для визуального отображения lsfd
, которое не аварийно завершится, если имена команд или файлов в выводе lsfd -J
не являются закодированным текстом UTF-8.
При передаче файла JSON в качестве аргумента в виде пути файла, затем, похоже, он декодирует входные данные в соответствии с параметрами --encoding
и --encoding-errors
, которые по умолчанию равны utf-8
и surrogateescape
соответственно, и уважает кодировку символов локали при выводе.
Таким образом, в оболочке с поддержкой подстановки процессов, такой как ksh, zsh, bash (или rc
, es
, akanga
с другим синтаксисом), вы просто можете сделать:
vd -f json <(lsfd -J | binary jq .lsfd)
Тем не менее, я замечаю, что иногда это вызывает сбой случайным образом для неординарных файлов, таких как те, которые являются трубами (см. эту другую проблему). Использование формата с одной строкой json (jsonl
) работает лучше:
vd -f jsonl <(lsfd -J | binary jq -c '.lsfd[]')
Или используйте форму =(...)
подстановки процессов в zsh (или (...|psub -f)
в fish
, то же самое, что (...|psub)
в текущих версиях), которая использует временный файл вместо трубы:
vd -f json =(lsfd -J | binary jq .lsfd)
Ответ или решение
Обработка JSON с недопустимыми символами UTF-8 является важной задачей для разработчиков и инженеров, которые работают с данными, созданными утилитами, генерирующими выходные данные в формате JSON, но не обрабатывающими некорректно закодированные строки. Давайте разберемся с вопросами и подходами, которые помогут решить эту проблему.
1. Как называется формат JSON с недопустимыми символами UTF-8?
Формат, в котором JSON-строки содержат недопустимые символы UTF-8 (т.е. байты, которые не соответствуют действительным последовательностям UTF-8), может быть неформально назван "битым" форматом. Более строго, это можно охарактеризовать как JSON, закодированный в некоторой кодировке, отличной от UTF-8 (например, Latin-1 или другой однобайтовой кодировке), где каждый байт напрямую отображается на соответствующий символ в Unicode, если такой имеется.
2. Существуют ли инструменты или модули программирования, которые надежно обрабатывают этот формат?
Да, есть несколько подходов, которые можно использовать:
-
Python: С помощью модуля
json
можно обрабатывать входные данные, используя кодировку Latin-1. Пример кода:import sys import json data = sys.stdin.buffer.read() string_data = data.decode("latin1") decoded_structure = json.loads(string_data) print(decoded_structure)
-
Perl: В Perl, используя
JSON
иacyjne параметры, вы можете указать кодировку выходного JSON как Latin-1:use JSON; my $decoder = JSON->new->latin1; my $data = <$input>; # Получаем ввод из данных my $decoded = $decoder->decode($data); print $decoded;
-
Python с
PYTHONIOENCODING
: Также в Python 3 можно использовать переменную окруженияPYTHONIOENCODING
, чтобы обрабатывать входящие данные как Latin-1 или включить поддержку суррогатных символов UTF-8.PYTHONIOENCODING=latin1 python3 script.py
3. Как преобразовать неподходящий формат в валидный JSON и обратно?
Можно использовать iconv
для преобразования символов из одной кодировки в другую. Например, сначала перетрансформируйте входные данные из Latin-1 в UTF-8, а затем после обработки с использованием обычных JSON-инструментов примените обратное преобразование.
Пример кода в командной строке:
cat input.json | iconv -f latin1 -t utf8 | jq '.' | iconv -f utf8 -t latin1 > output.json
Общие рекомендации
-
Минимизируйте недопустимые символы: Если вы контролируете формат данных, старайтесь избегать использования символов, которые не могут быть представлены в UTF-8.
-
Проверка на этапе разработки: Если вы разрабатываете утилиты, которые генерируют JSON, проводите проверки на допустимость данных перед выводом. Используйте утилиты для линтинга JSON, такие как
jsonlint
, для удостоверения, что JSON-выход соответствует стандарту. -
Обработка ошибок: Всегда учитывайте возможные ошибки при обработке данных, включайте обработку исключений для случаев, когда вход не соответствует ожидаемому формату.
Заключение
Работа с JSON, содержащими недопустимые символы UTF-8, может быть сложной, но использование предложенных инструментов и подходов позволит вам эффективно обрабатывать такие данные. Следуя рекомендациям и применяя навыки программирования, вы сможете решить эту проблему и улучшить стабильность Ваших проектов.