Парсинг JSON — це мінне поле

image

JSON — це стандарт де-факто, коли заходить мова про (де)серіалізації, обмін даними в мережі мобільного розробці. Але наскільки добре ви знайомі з JSON? Всі ми читаємо специфікації і пишемо тести, відчуваємо популярні JSON-бібліотеки для своїх потреб. Я покажу вам, що JSON — це ідеалізований формат, а не ідеальний, яким його багато хто вважає. Я не знайшов і двох бібліотек, які ведуть себе однаково. Більше того, я виявив, що крайні випадки і шкідлива корисне навантаження можуть призвести до багам, падіннями і DoS, в основному тому, що JSON-бібліотеки засновані на специфікаціях, які з часом розвиваються, що залишає багато речей погано або взагалі не задокументованими.

Зміст1. Специфікації JSON
2. Тестування парсинга
2.1. Структура
2.2. Числа (Numbers)
2.3. Масиви
2.4. Об'єкти
2.5. Рядка
2.6. Двоїсті значення RFC 7159
3. Архітектура тестування
4. Результати тестування
4.1. Повні результати
4.2. C-парсери
4.3. Objective-C-парсери
4.4. Apple (NS)JSONSerialization
4.5. Freddy (Swift)
4.6. Bash JSON.sh
4.7. Інші парсери
4.8. JSON Checker
4.9. Регулярні вирази
5. Контент парсинга
6. STJSON
7. Висновок
8. Додаток
1. Специфікації JSON
JSON — стандарт де-факто серіалізації при передачі даних по HTTP, лінгва франка для обміну даними між гетерогенними додатками як у веб-, так і в мобільній розробці.

У 2001 році Дуглас Крокфорд розробив таку коротку і просту специфікацію JSON, що це породило виникнення візитних карток, на звороті яких друкували повну граматику JSON.



JSON використовують практично всі користувачі інтернету і програмісти, але лише деякі дійсно прийшли до згоди щодо того, як повинен працювати JSON. Стислість граматики залишає багато аспектів невизначеними. До того ж існує кілька специфікацій і їх каламутних інтерпретацій.

Крокфорд вирішив не версионировать JSON:

Ймовірно, моїм самим сміливим дизайн-рішенням став відмову від присвоєння JSON номерних версій, тому відсутній механізм внесення змін. Ми застрягли з JSON: що б не представляла собою його поточна форма, він тільки такий.


Крім того, JSON визначається як мінімум в шести різних документах:

  1. 2002 — json.org і візитки.
  2. 2006 — IETF RFC 4627, встановлює application/json MIME тип середовища.
  3. 2011 — ECMAScript 262, розділ 15.12.
  4. 2013 — ECMA 404. Як повідомив Тім Брей (редактор RFC 7159), ECMA поквапилася з релізом, тому що:
    Хтось сказав робочій групі ECMA, що IETF з'їхала з глузду і зібралася переписати JSON без оглядки на сумісність і поломку всього інтернету, і з цією жахливою ситуацією потрібно терміново щось робити. <...> Це не має ніякого відношення до скарг, які вплинули на ревізію з боку IETF.
  5. 2014 — IETF RFC 7158. Створює специфікацію «Standard Tracks» замість «Informational»; дозволяє використовувати скаляри (нічого, крім масивів та об'єктів) начебто 123 і true на root-рівні, як і ECMA; застерігає від застосування невдалих рішень начебто повторюваних ключів або зламаних Unicode рядків, хоча і не забороняє їх явно.
  6. 2014 — IETF RFC 7159. Випущений для виправлення помилки в RFC 7158, який був датований березнем 2013-го замість березня 2014-го.
Незважаючи на ясність, RFC 7159 містить кілька допущень і залишає чимало погано освітлених моментів.

Зокрема, в RFC 7159 згадується, що метою розробки JSON було створити «підмножина JavaScript», але насправді це не так. Наприклад, JSON дозволяє використовувати екрановані (unescaped) символи кінця рядка з Unicode
U+2028 LINE SEPARATOR
та
U+2029 PARAGRAPH SEPARATOR
. Але специфікація JavaScript свідчить, що рядкові значення не можуть містити символи кінця рядка (ECMA-262 — 7.8.4 String Literals), і взагалі до цих символів відносяться
U+2028
та
U+2029
(7.3 Line Terminators). Той факт, що ці два символи можуть використовуватися в JSON-рядках без захисту, а в JS вони взагалі не розуміються, говорить про те, що JSON є підмножиною JavaScript, незважаючи на визначені цілі розробки.

Також RFC 7159 не прояснює, як JSON-парсер повинен звертатися з граничними значеннями (extreme number values), спотвореними Unicode-рядками, однаковими об'єктами або глибиною рекурсії. Одні тупикові ситуації явно залишені без реалізацій, а інші страждають від суперечливих висловлювань.

Щоб проілюструвати неточність RFC 7159, я написав збірник тестових JSON-файлів і задокументував, як конкретні JSON-парсери їх обробляють. Нижче ви побачите, що не завжди легко вирішити, чи варто парсити той або інший тестовий файл. У своїх дослідженнях я виявив, що все парсери ведуть себе по-різному, і це може приводити до серйозних проблем з сумісністю.

2. Тестування парсинга
Далі я поясню, як створити тестові файли для перевірки поведінки парсерів, розповім про деяких цікавих тестах і обосную, повинні парсери, що відповідають критеріям RFC 7159, приймати або відкидати файли — або ж вирішувати самостійно.

Імена файлів, що починаються з букви, яка говорить про очікуваний результат:

  • y
    (yes) — успішний парсинг;
  • n
    (no) — помилка парсинга;
  • i
    (implementation) — залежить від реалізації.
Також з файлів буде зрозуміло, який саме компонент парсера піддавався тестування.

Наприклад,
n_string_unescaped_tab.json
містить
["09"]
— це масив з рядком, що включає в себе символ
TAB 0x09
, який ПОВИНЕН бути екранований (u-escaped) згідно специфікаціям JSON. Файл тестує парсинг рядків, тому в назві міститься
string
, а не
structure
,
array
або
object
. Згідно RFC 7159 це невалидное рядкове значення, тому в імені файлу присутній
n
.

Зверніть увагу, що кілька парсерів не допускають скаляров на верхньому рівні (
"test"
), тому я вмонтував рядка в масиви (
["test"]
).

Більше 300 тестових файлів ви можете знайти в репозиторії JSONTestSuite.

В більшості своїй файли я робив вручну по мірі читання специфікацій, намагаючись приділити увагу надзвичайних ситуацій та неоднозначних моментів. Я також намагався використати напрацювання з чужих тестових наборів, знайдених в інтернеті (в основному json test suite і JSON Checker, але виявив, що більшість з них покривають тільки базові ситуації.

Нарешті, я генерував JSON-файли за допомогою фаззингового American Fuzzy Lop. Потім прибрав надлишкові тести, що приводять до одного результату, а потім скоротив кількість, щоб вийшло найменшу кількість символів, що дають результати (див. розділ 3).

2.1. Структура
Скаляри — очевидно, що необхідно парсити скаляри зразок 123 або «asd». На практиці багато популярні парсери все ще реалізують RFC 4627 і не стануть парсити поодинокі значення. Таким чином, є основні тести, наприклад:

y_structure_lonely_string.json "asd"

Замикаючі коми (trailing commas), наприклад
[123,]
або
{"a":1,}
, не є частиною граматики, тому такі файли не повинні проходити тести, вірно? Але справа в тому, що RFC 7159 дозволяє парсерам підтримувати «розширення» (розділ 9), хоча пояснень щодо них не дається. На практиці замикаючі коми — поширене розширення. Оскільки це не частина JSON-граматики, парсери не зобов'язані підтримувати їх, так що імена файлів починаються з n.

n_object_trailing_comma.json {"id":0,}
n_object_several_trailing_commas.json {"id":0,,,,,}

Коментарі теж не частина граматики. Крокфорд прибрав їх з ранніх специфікацій. Але це ще одне поширене розширення. Деякі парсери допускають використання коментарів, замикаючих
[1]//xxx
або навіть вбудованих
[1,/*xxx*/2]
.

y_string_comments.json ["a/*b*/c/*d//e"]
n_object_trailing_comment.json {"a":"b"}/**/
n_structure_object_with_comment.json {"a":/*comment*/"b"}

Незамкнуті структури. Тести охоплюють всі ситуації, коли є відкриті і не закриті (або навпаки) структури, наприклад
[
або
[1,{,3]
. Очевидно, що це помилка і тести не повинні бути пройдені.

n_structure_object_unclosed_no_value.json {"":
n_structure_object_followed_by_closing_object.json {}}

Вкладені структури. Структури іноді містять інші структури, масиви — інші масиви. Перший елемент може бути масивом, чий перший елемент — теж масив, і так далі, немов матрьошка
[[[[[]]]]]
. RFC 7159 дозволяє парсерам встановлювати обмеження на максимальну глибину вкладеності (розділ 9).

Кілька парсерів не обмежують глибину і в якийсь момент просто падають. Наприклад, Xcode впаде, якщо відкрити файл .json, що містить тисячу символів
[
. Ймовірно, тому, що в выделителе синтаксичних елементів JSON не реалізовано обмеження глибини.

$ python -c "print('['*100000)" > ~/x.json
$ ./Xcode ~/x.json
Segmentation fault: 11

Прогалини. Граматика RFC 7159 дозволяє використовувати їх як
0x20
(пробіл),
0x09
(табуляцію),
0x0A
(переклад рядка) і
0x0D
(повернення каретки). Прогалини допускаються до і після «структурних символів» (structural characters)
[]{}:,
. Так що
20[090A]0D
пройде тести. І навпаки, файл не пройде тести, якщо ми включимо в нього всі види прогалин, які не можна явно, наприклад форму введення
0x0C
або
[E281A0]
— UTF-8 для позначення з'єднувача слів
U+2060 WORD JOINER
.

n_structure_whitespace_formfeed.json [0C]
n_structure_whitespace_U+2060_word_joiner.json [E281A0]
n_structure_no_data.json

2.2. Числа
NaN і Infinity. Рядки, що описують спеціальні числа, на зразок
NaN
або
Infinity
, не є частиною граматики JSON. Але деякі парсери їх приймають, розцінюючи як «розширення» (розділ 9). У тестових файлах також перевіряються негативні форми
NaN
та
Infinity
.

n_number_NaN.json [NaN]
n_number_minus_infinity.json [-Infinity]

Шістнадцяткові числа — RFC 7159 не допускає їх використання. Тести містять числа зразок
0xFF
, і такі файли не повинні проходити парсинг.

n_number_hex_2_digits.json [0x42]

Діапазон і точність — а що щодо чисел з величезної кількості цифр? Згідно RFC 7159, «JSON-парсер ПОВИНЕН приймати всі види текстів, відповідних граматиці JSON» (глава 9). Але в тому ж параграфі йдеться: «Реалізація може обмежувати діапазон і точність чисел». Так що мені незрозуміло, чи можуть парсери видавати помилку, стикаючись зі значеннями зразок
1e9999
або
0.0000000000000000000000000000001
.

y_number_very_big_negative_int.json [-237462374673276894279832(...)

Експоненціальні подання — їх парсинг може бути напрочуд важким завданням (див. главу з результатами). Є й валідні (
[0E0]
,
[0e+1]
), і невалідні варіанти (
[1.0 e+]
,
[0E]
та
[1eE2]
).

n_number_0_capital_E+.json [0E+]
n_number_.2e-3.json [.2e-3]
y_number_double_huge_neg_exp.json [123.456 e-789]

2.3. Масиви
Більшість надзвичайних ситуацій, пов'язаних з масивами, — це проблеми з відкриванням/закриванням і обмеженням вкладеності. Вони розглянуті в розділі 2.1 (Структури). Тести пройдуть
[[]
та
[[]]]
, а не пройдуть
]
або
[[]]]
.

n_array_comma_and_number.json [,1]
n_array_colon_instead_of_comma.json ["": 1]
n_array_unclosed_with_new_lines.json [1,0A10A,1

2.4. Об'єкти
Повторювані ключі. розділі 4 RFC 7159 говориться: «В межах об'єкта повинні мати унікальні імена». Це не запобігає парсинг об'єктів, в яких один ключ з'являється кілька разів
{"a":1,"a":2}
, але дозволяє парсерам самим вирішувати, що робити в таких випадках. У розділі 4 навіть згадується, що «[деякі] реалізації повідомляють про помилки або збої під час парсинга об'єкта», без уточнення, чи відповідає збій парсинга положень RFC, в особливості : «JSON-парсер ПОВИНЕН приймати всі види текстів, відповідних граматиці JSON».

Варіанти таких особливих випадків включають в себе однаковий ключ: одне і те ж значення
{"a":1,"a":1}
, а також ключі або значення, чия однаковість залежить від способу порівняння рядків. Наприклад, ключі можуть бути різними в двійковому виразі, але еквівалентними у відповідності з нормалізацією Inicode NFC:
{"C3A9:"NFC","65CC81":"NFD"}
, тут обидва ключа позначають "é". Також тести включена перевірка
{"a":0,"a":-0}
.

y_object_empty_key.json {"":0}
y_object_duplicated_key_and_value.json {"a":"b","a":"b"}
n_object_double_colon.json {"x"::"b"}
n_object_key_with_single_quotes.json {key: 'value'}
n_object_missing_key.json {:"b"}
n_object_non_string_key.json {1:1}

2.5. Рядки
Кодування файлу. «JSON-текст ПОВИНЕН бути в кодуванні UTF-8, UTF-16 або UTF-32. За замовчуванням використовується UTF-8» (розділ 8.1).
Так що для проходження тестів необхідна одна з трьох кодувань. Тексти в UTF-16 і UTF-32 також повинні містити старші і молодші варіанти.

Збійні тести включають в себе рядки в кодуванні ISO Latin-1.

y_string_utf16.json FFFE[00"00E900"00]00
n_string_iso_latin_1.json ["E9"]

Маркер послідовності байтів (Byte Order Mark). Хоча в розділі 8.1 заявлено: «Реалізації НЕ ПОВИННІ додавати маркер послідовності байтів в початок JSON-тексту», потім ми бачимо: «Реалізації… МОЖУТЬ ігнорувати наявність маркера, а не розглядати його як помилку».

Збійні тести включають в себе лише позначки в кодуванні UTF-8, без іншого контенту. Тести, результати яких залежать від реалізації, включають в себе UTF-8 BOM з UTF-8 рядок, а також UTF-8 BOM з UTF-16 рядком і UTF-16 BOM з UTF-8 рядком.

n_structure_UTF8_BOM_no_data.json EFBBBF
n_structure_incomplete_UTF8_BOM.json EFBB{}
i_structure_UTF-8_BOM_empty_object.json EFBBBF{}

Керуючі символи повинні бути ізольовані і визначені як
U+0000
у вигляді
U+001F
(розділ 7). Сюди не входить символ 0x7F DEL, який може бути частиною інших визначень керуючих символів (див. розділ 4.6, Bash JSON.sh). Тому тести повинен пройти
["7F"]
.

n_string_unescaped_ctrl_char.json ["a\09a"]
y_string_unescaped_char_delete.json ["7F"]
n_string_escape_x.json ["\x00"]

Екранування. «Всі символи можуть бути екрановані» (розділ 7), наприклад \uXXXX. Але деякі — лапки, зворотний слеш і керуючі символи ПОВИННІ бути екрановані. У збійні тести включені символи екранування без экранируемых значень або значень з незавершеним екрануванням. Приклади:
["\"]
,
["\
,
[\
.

y_string_allowed_escapes.json ["\"\\/\b\f\n\r\t"]
n_structure_bad_escape.json ["\

Символ екранування може використовуватися для представлення кодових точок (codepoints) на базовому багатомовному рівні (Basic Multilingual Plane, BMP) (
\u005C
). Успішні тести включають в себе нульовий символ (zero character)
\u0000
, який може призводити до проблем в парсерах на С. Збійні тести включають в себе головну U
\U005C
, нешестнадцатеричные екрановані значення
\u123Z
і значення з незавершеним екрануванням
\u123
.

y_string_backslash_and_u_escaped_zero.json ["\u0000"]
n_string_invalid_unicode_escape.json ["\uqqqq"]
n_string_incomplete_escaped_character.json ["\u00A"]

Екрановані не Unicode-символи

Кодові точки поза BMP представлені екранованими сурогатами в кодуванні UTF-16:
+1D11E
стає
\uD834\uDD1E
. Успішні тести включають в себе поодинокі сурогати, оскільки вони валідні з точки зору JSON-граматики. Помилка 3984 RFC 7159 породила проблему граматично коректних екранованих кодових точок, які не є Unicode-символами (
\uDEAD
), або несимволов з
U+FDD0
на
U+10FFFE
.

У той же час доповнена форма Бекуса — Наура (ABNF, Augmented Backus — Naur form) не допускає використання не відповідних Unicode кодових точок (розділ 7) і вимагає відповідності Unicode (розділ 1).

Редактори вирішили, що граматика не повинна обмежуватися і що достатньо попередити користувачів про «непередбачуваності» (RFC 7159, розділ 8.2) поведінки парсерів. Іншими словами, парсери ПОВИННІ парсити u-екрановані несимволы, але результат непередбачуваний. У таких випадках імена файлів, що починаються з префікса
i_
(залежить від реалізації). Згідно стандарту Unicode, невірні кодові точки повинні бути замінені на символ заміни
U+FFFD REPLACEMENT CHARACTER
. Якщо ви вже стикалися з складністю Unicode, вас не здивує, що заміна обов'язковою до виконання і може здійснюватися різними способами (див. Unicode PR #121: Рекомендовані методики для заміни символів). Тому одні парсери використовують символи заміни, а інші залишають екрановану форму або генерують не Unicode-символ (див. розділ 5 — Вміст парсинга).

y_string_accepted_surrogate_pair.json ["\uD801\udc37"]
n_string_incomplete_escaped_character.json ["\u00A"]
i_string_incomplete_surrogates_escape_valid.json ["\uD800\uD800\n"]
i_string_lone_second_surrogate.json ["\uDFAA"]
i_string_1st_valid_surrogate_2nd_invalid.json ["\uD888\u1234"]
i_string_inverted_surrogates_U+1D11E.json ["\uDd1e\uD834"]

Звичайні (raw) не Unicode-символи

У попередньому розділі ми обговорили не Unicode — кодові точки, що виникають у рядках (
\uDEAD
). Ці точки є валідним Unicode в u-екранованої формі, але не декодуються в Unicode символи.

Парсери також повинні обробляти звичайні байти, не кодують Unicode символи. Наприклад, в UTF-8 байт FF не є Unicode-символом. Отже, значення рядка, що містить FF, — це не рядок в кодуванні UTF-8. У такому разі парсер повинен просто відмовитися парсити, тому що «значення Рядка — це послідовність Unicode-символів в кількості від нуля і більше» (RFC 7159, розділ 1) і «JSON-текст ПОВИНЕН бути представлений в кодуванні Unicode» (RFC 7159, розділ 8.1).

y_string_utf8.json ["€?"]
n_string_invalid_utf-8.json ["FF"]
n_array_invalid_utf8.json [FF]

Двозначності RFC 7159

Окрім специфічних випадків, які ми розглянули, практично неможливо встановити, відповідає чи парсер вимог RFC 7159, внаслідок сказаного розділі 9:

JSON-парсер ПОВИНЕН приймати всі тексти, відповідні граматиці JSON. JSON-парсер МОЖЕ приймати не JSON форми або розширення.


Поки все зрозуміло. Всі граматично правильні вхідні дані ПОВИННІ парситься, і парсери можуть самі вирішувати, приймати чи інший контент.

Реалізації можуть обмежувати:

  • розмір прийнятого тексту;
  • максимальну глибину вкладеності;

  • діапазон і точність чисел;
  • довжину значень рядка і їх набір символів.


Всі ці обмеження звучать розумно (за винятком, можливо, символів), але суперечать слова «ПОВИНЕН» з попередньої цитати. RFC 2119 гранично ясно пояснює його значення:

МАЄ. Це слово, як і «ПОТРІБНО» або «СЛІД», що означає обов'язкову вимогу специфікації.


RFC 7159 допускає обмеження, але не ставить мінімальні вимоги. Тому технічно парсер, який не може парсити рядок довше трьох символів, все ще відповідає вимогам RFC 7159.

Крім того, у розділі 9 RFC 7159 від парсерів потрібно чітко документувати обмеження та/або дозволити використовувати користувальницькі конфігурації. Але ці зміни можуть призводити до проблем з сумісністю, тому краще зупинятися на мінімальних вимогах.

Такий недолік конкретики на тлі допустимих обмежень практично не дозволяє точно сказати, чи відповідає парсер RFC 7159. Адже можна парсити контент, не відповідає граматиці (це «розширення»), і відхиляти контент, що відповідає граматиці (це «обмеження» парсера).

3. Архітектура тестування
Я хотів подивитися, як насправді поведуть себе парсери, незалежно від того, як вони повинні себе поводити. Тому вибрав кілька JSON-парсерів і налаштував все так, щоб можна було згодовувати їм свої тестові файли.

Оскільки я Cocoa-розробник, більшість парсерів написані на Swift і Objective-C. Але є і досить довільно вибрані парсери на C, Python, Ruby, R, Lua, Perl, Bash і Rust. В основному я намагався охопити різноманітні за віком і популярності мови.

Деякі парсери дозволяють посилювати або послаблювати суворість обмежень, налаштовувати підтримку Unicode або використовувати специфічні розширення. Я прагнув завжди конфігурувати парсери, щоб вони працювали як можна ближче до найбільш суворої інтерпретації RFC 7159.

Python-скрипт
run_tests.py
проганяв через кожен парсер кожен тестовий файл (або одиночний тест, якщо файл передається у вигляді аргументу). Зазвичай парсери були в обгортках і повертали 0 у разі успіху і 1 у разі невдачі парсингу. Був передбачений окремий статус для падіння парсера, а також таймаут — 5 секунд. По суті, я перетворив JSON-парсери в JSON-валідатори.

run_tests.py
порівнював обчислене значення по кожному тесту з очікуваним результатом, відбитим у префіксі імені файлу. Якщо вони не збігалися або коли префікс був
i
(залежить від реалізації),
run_tests.py
записував у журнал (
results/logs.txt
) рядок певного формату:

Python 2.7.10 SHOULD_HAVE_FAILED n_number_infinity.json



Потім
run_tests.py
зчитував журнал і генерував HTML-таблиці з результатами (
results/parsing.html
).

У кожній рядку знаходяться результати для одного з файлів. Парсери представлені в колонках. Для різних результатів передбачені різні кольори заливки клітинок:



Тести відсортовані за результатами. Це полегшує пошук схожих результатів і видалення надлишкових.



4. Результати і коментарі
4.1. Повні результати
Повні результати тестування можна знайти тут: seriot.ch/json/parsing.html. Тести відсортовані по схожості результатів. В
run_tests.py
є опція, що дозволяє виводити «скорочені результати» (pruned results): коли набір тестів дає однакові результати, то зберігається тільки перший тест. Файл з скороченими даними доступний тут: www.seriot.ch/json/parsing_pruned.html.

Падіння (червоний колір) — найсерйозніша проблема, оскільки парсинг неконтрольованих вхідних даних піддає ризику весь процес. Тести «очікувалося успішне виконання» (коричневий колір) також дуже небезпечні: неконтрольовані вхідні дані можуть не дати отпарсить весь документ. Менш небезпечні тести «очікувався збій виконання» (жовтий колір). Вони говорять про «розширення», які не можна отпарсить. Так що все стане працювати до тих пір, поки парсер не буде замінений іншим, який не вміє парсити ці «розширення».



Далі я розгляну і прокоментую самі примітні результати.

4.2. C-парсери
Я вибрав п'ять C-парсерів:

Коротка порівняльна таблиця:



Більше подробиць можна знайти в таблиці повних результатів.

4.3. Objective-C-парсери
Я вибрав три Objective-C-парсера, дуже популярних на зорі iOS-розробки, особливо тому, що Apple до iOS 5 не випускала NSJSONSerialization. Всі три парсера було цікаво протестувати, оскільки вони використовувалися при розробці багатьох додатків.

Коротка порівняльна таблиця:



SBJSON вижив після появи NSJSONSerialization, він до цих пір підтримується, його можна завантажити через CocoaPods. Тому в заявці #219 я зарепортил падіння, коли парсил не UTF-8 рядок на зразок [FF].

*** Assertion failure in -[SBJson4Parser parserFound:isValue:], SBJson4Parser.m:150
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid parameter not satisfying: obj'
*** First throw call stack:
(
0 CoreFoundation 0x00007fff95f4b4f2 __exceptionPreprocess + 178
1 libobjc.A.dylib 0x00007fff9783bf7e objc_exception_throw + 48
2 CoreFoundation 0x00007fff95f501ca +[NSException raise:format:arguments:] + 106
3 Foundation 0x00007fff9ce86856 -[NSAssertionHandler handleFailureInMethod:object:file:lineNumber:description:] + 198
4 test_SBJSON 0x00000001000067e5 -[SBJson4Parser parserFound:isValue:] + 309
5 test_SBJSON 0x00000001000073f3 -[SBJson4Parser parserFoundString:] + 67
6 test_SBJSON 0x0000000100004289 -[SBJson4StreamParser parse:] + 2377
7 test_SBJSON 0x0000000100007989 -[SBJson4Parser parse:] + 73
8 test_SBJSON 0x0000000100005d0d main + 221
9 libdyld.dylib 0x00007fff929ea5ad start + 1
)
libc++abi.dylib: terminating with uncaught exception of type NSException

4.4. Apple (NS)JSONSerialization
developer.apple.com/reference/foundation/nsjsonserialization

NSJSONSerialization з'явився з iOS 5, і з тих пір це стандартний JSON-парсер на OS X і iOS. Він доступний на Objective-C і був переписаний на Swift: NSJSONSerialization.swift. В Swift 3 префікс NS отбросили.

Обмеження і розширення

У JSONSerialization є незадокументированные обмеження:

  • Він не парсити великі числа:
    [123123e100000]
  • Він не парсити u-екрановані помилкові кодові точки:
    ["\ud800"]
У JSONSerialization є незадокументированное розширення:

  • Він парсити замикаючі коми:
    [1,]
    та
    {"a":0,}
Самим проблемним я вважаю обмеження, пов'язане з кодовими точками, особливо у такого популярного парсера. Спроба парсинга неконтрольованого вмісту може призвести до збою парсинга.

Падіння при серіалізації

Цей розділ більше про JSON-парсинг, а не JSON-розробку. Але я вирішив згадати про це падіння, з якими зіткнувся, коли JSONSerialization записував
Double.nan
. Як ви пам'ятаєте,
NaN
не відповідає граматиці JSON, тому JSONSerialization повинен був видати помилку, а не обрушити весь процес.

do {
let a = [Double.nan]
let data = try JSONSerialization.data(withJSONObject: a, options: [])
} catch let e {
}

SIGABRT

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Invalid number value (NaN) in JSON write'

4.5. Freddy (Swift)
Freddy (https://github.com/bignerdranch/Freddy) — це справжній JSON-парсер, написаний на Swift 3. Я кажу «справжній», тому що кілька GitHub-проектів заявляють себе як Swift JSON-парсери, хоча насправді використовують Apple JSONSerialization і просто мапят JSON-контент в об'єкт-моделі.

Freddy цікавий тим, що написаний знаменитої групою Cocoa-розробників і експлуатує безпека типів Swift з допомогою використання Swift-переказів для представлення різних JSON-вузлів (Array, Dictionary, Double, Int, String, Bool і Null).

Але Freddy випущений в січні 2016-го, він ще молодий і забагован. Мій тестовий набір продемонстрував, що парсер падає на незакритих структурах на зразок
[1
та
{"a":
, і на рядку у вигляді одного пробілу " ". Я відкрив заявку #199, і баг пофиксили за один день!

Також я виявив, що
"0e1"
помилково відхиляється парсером, про що написав у заявці #198, і цей баг теж пофиксили за один день.

Тим не менш станом на 18 жовтня Freddy все ще падає при парсингу
["
\
. Про ба я повідомив в заявці #206.

У цій таблиці відображена еволюція поведінки Freddy:



4.6. Bash JSON.sh
Я тестував github.com/dominictarr/JSON.sh, версію від 12 серпня 2016 року.

У цьому Bash-парсере регулярні вирази відповідають за пошук керуючих символів, які, згідно RFC 7159, ПОВИННІ бути екрановані з допомогою зворотніх слешів. Але у Bash і JSON різні уявлення про те, що таке керуючі символи.

Регулярні вирази для зіставлення керуючих символів використовують синтаксис
:cntlr:
. Це скорочена форма
[\x00-\x1F\x7F]
. Але за правилами граматики JSON
0x7F DEL
не відноситься до керуючих символів і може не екрановані.

00 nul 01 soh 02 stx 03 etx 04 eot 05 enq 06 ack 07 bel
08 bs 09 ht 0a nl 0b vt 0c np 0d cr 0e so 0f si
10 dle 11 dc1 12 dc2 13 dc3 14 dc4 15 nak 16 syn 17 etb
18 can 19 em 1a sub 1b esc 1c fs 1d gs 1e rs 1f us
20 sp 21 ! 22 " 23 # 24 $ 25 % 26 & 27 '
28 ( 29 ) 2a * 2b + 2c , 2d — 2e . 2f /
30 0 31 1 32 2 33 3 34 4 35 5 36 6 37 7
38 8 39 9 3a : 3b ; 3c < 3d = 3e > 3f ?
40 @ 41 A 42 B 43 C 44 D 45 E 46 F G 47
48 H 49 I 4a J 4b K 4c L 4d M 4e N 4f O
50 P 51 Q 52 R 53 54 S U T 55 56 V W 57
58 X 59 Y 5a Z 5b [ 5c \ 5d ] 5e ^ 5f _
60 ` 61 62 a b 63 c 64 d 65 66 e f g 67
68 h 69 i 6a j 6b k 6c l 6d m 6e n 6f o
70 p 71 q 72 r 73 s 74 t 75 u 76 v w 77
78 x 79 y 7a z 7b { 7c | 7d } 7e ~ 7f del

В результаті JSON.sh не може парсити
["7F"]
. Я зарепортил цей баг. Також JSON.sh не обмежує глибину вкладеності і падає при парсингу 10 000 символів відкривання масиву [. Про це я теж повідомив.

$ python -c "print('['*100000)" | ./JSON.sh 
./JSON.sh: line 206: 40694 Done tokenize
40695 Segmentation fault: 11 | parse

4.7. Інші парсери
Крім C / Objective-C і Swift, я тестував парсери і з інших середовищ. Ось короткий огляд їх розширень і обмежень з вибіркою повних результатів тестування. Таблиця покликана показати, що не знайдеться і двох парсерів, які повністю збігаються в думці, що добре і що погано.



Посилання на протестовані парсери:

На численні прохання я також додав Java-парсери, які не відображені у стислій таблиці, але присутні в повних результати:

JSON-модуль Python парсити
NaN
та
Infinity
як числа. Це можна виправити, змінивши опції parse_constant у функції, що викидає виключення, як показано нижче. Але таке рішення зустрічається рідко, тому я не використовував його в тестах і дозволив парсеру помилково парсити ці числові константи.

def f_parse_constant(o):
raise ValueError

o = json.loads(data, parse_constant=f_parse_constant)

4.8. JSON Checker
JSON-парсер перетворює JSON-документ в інше уявлення. Якщо вхідні дані є некоректним JSON, то парсер повертає помилку.

Деякі програми не перетворять вхідні дані, а просто повідомляють про коректність або некоректність JSON. Такі програми — це JSON-валідатори.

Одна з них написана на С і називається JSON_Checker. Її можна завантажити з www.json.org/JSON_checker, і з нею навіть йде тестовий набір (маленький):

JSON_Checker — це pushdown automaton програма, яка дуже швидко визначає синтаксичну коректність JSON-тексту. Вона може використовуватися для фільтрування вхідних даних або для перевірки вихідних даних на синтаксичну коректність. JSON_Checker можна адаптувати для створення дуже швидкого JSON-парсера.


Хоча JSON_Checker формально не є референсною реалізацією, все ж можна очікувати, що він уточнить вимоги JSON-специфікації або хоча б коректно їх реалізує.

На жаль, JSON_Checker порушує специфікації, визначені на тому ж сайті. Наприклад, він парсити
[1.]
,
[0.e1]
, що не відповідає граматиці JSON.

Більш того, JSON_Checker відхиляє
[0e1]
, абсолютно валидное JSON-число. Це самий серйозний баг, тому що із-за наявності числа
0e1
може бути відхилений весь документ.

Елегантність реалізації JSON_Checker як pushdown автомат не скасовує помилковості коду, але хоча б таблиця переходу станів полегшує виявлення помилок, особливо коли ви додаєте стану в схему того, що є числом.



Баг 1: відхилення 0e1. У коді станом
ZE
, досягнутого після парсинга
0
, не вистачає переходів до
E1
з допомогою читання
e
або
E
. Це можна виправити, додавши два відсутніх переходу.

Баг 2: прийняття [1.]. В одних випадках, наприклад
0.
, граматика вимагає наявності цифри. А в інших, наприклад
1.
, не вимагає.

JSON_Checker все ще визначає одне стан
FR
, а не два. Це можна виправити, замінивши на схемі червоне стан
FR
новим станом
F0
або
frac0
. Тоді після
1.
парсер буде вимагати цифру.



Ряд інших парсерів (Obj-C TouchJSON, PHP, R rjson, Rust json-rust, Bash JSON.sh, C jsmn і Lua dkjson) теж помилково парсят
[1.]
. Як цей баг поширився з JSON_Checker? Просто розробники парсерів і тестери використовують його в якості референса, як це радиться на json.org.

4.9. Регулярні вирази
Можуть регулярні вирази перевіряти відповідність вхідних даних граматики JSON? Подивіться, наприклад, на спробу знайти найкоротший регулярний вираз. Проблема в тому, що без серйозного тестування дуже важко дізнатися, увінчалися успіхом дії регулярних виразів.

Я знайшов на StackOverflow одне з кращих регулярних виразів на Ruby для валідації JSON:

JSON_VALIDATOR_RE = /(
# define subtypes and build up the json syntax, BNF-grammar-style
# The {0} is a to hack simply define them as named groups here but not match on them yet
# I added some atomic grouping to prevent catastrophic backtracking on invalid inputs
(?<number> -?(?=[1-9]|0(?!\d))\d+(\.\d+)?([eE][+-]?\d+)?){0}
(?<boolean> true | false | null ){0}
(?<string> " (?>[^"\\\\]* | \\\\ ["\\\\ bfnrt\/] | \\\\ u [0-9a-f]{4} )* " ){0}
(?<array> \[ (?> \g<json> (?: , \g<json> )* )? \s* \] ){0}
(?<pair> \s* \g<string> \s* : \g<json> ){0}
(?<object> \{ (?> \g<pair> (?: , \g<pair> )* )? \s* \} ){0}
(?<json> \s* (?> \g<number> | \g<boolean> | \g<string> | \g<array> | \g<object> ) \s* ){0}
)
\A \g<json> \Z
/uix

Воно не може парсити валідний JSON, наприклад:

  • u-екрановані кодові точки, включаючи валідні:
    ["\u002c"]
  • зворотний слеш, екранований зворотним слешем:
    ["\\a"]
Також воно парсити наступні розширення (а це баг для JSON-валідатора):

  • True з великої літери:
    [True]
  • неекранований керуючий символ
    ["09"]
5. Контент парсинга
RFC 7159 (розділ 9) сказано:

JSON-парсер перетворює JSON-текст в інше уявлення.


Вся вищеперелічена архітектура тестування говорить лише про те, буде чи парсер парсити JSON-документ, але нічого не повідомляє про подання отриманого вмісту.

Наприклад, можна без помилки отпарсить u-екранований неправильний Unicode-символ
"\uDEAD"
), але яким буде результат? Символ заміни або щось інше? В RFC 7159 про це ні слова.

А що щодо екстремальних чисел зразок
0.00000000000000000000001
та
-0
? Їх можна отпарсить, але що ми отримаємо? RFC 7159 не поділяє цілочисельні значення з плаваючою комою або 0 -0. Там навіть не сказано, чи можна конвертувати числа рядка.

Або як бути з об'єктами, що містять однакові ключі (
{"a":1,"a":2}
)? Чи однакові ключі і значення (
{"a":1,"a":1}
)? А як парсер повинен порівнювати ключі об'єкта? У двійковому поданні або нормальної Unicode-формі, як NFC? В RFC немає відповіді.

У всіх цих випадках парсери можуть робити з вихідними даними, що завгодно. Звідси — проблеми з сумісністю (подумайте, що може піти не так, якщо ви вирішите змінити свій звичний JSON-парсер на інший).

Враховуючи вищесказане, давайте проведемо тестування на невизначеність подання після парсингу. Ці тести потрібні тільки для розуміння, як можуть відрізнятися вихідні дані парсерів. На відміну від перевірки парсинга, ці тести важко автоматизувати. Так що результати отримані за допомогою аналізу журналів виразів (log statements) та/або отладчиков.

Нижче представлений список деяких разючих відмінностей між фінальними уявленнями після парсингу. Повні результати можна переглянути у розділі «Контент парсинга».



  • 1.000000000000000005
    зазвичай конвертується в значення з плаваючою комою
    1.0
    , але Rust 1.12.0 / json 0.10.2 зберігають вихідну точність і використовують число
    1.000000000000000005
  • 1E-999
    зазвичай конвертується в числа з плаваючою комою або подвійною точністю (double)
    0.0
    , але Freddy видає рядок
    "1E-999"
    . Swift Apple JSONSerializattion і Obj-C JSONKit відмовляються парсити і видають помилку.
  • 10000000000000000999
    може бути конвертовано в число подвійної точності (Swift Apple JSONSerialization), unsigned long long (Objective-C JSONKit) або рядок (Swift Freddy). Треба зазначити, що cJSON парсити його як число подвійної точності, але в процесі втрачає точність і видає нове число
    10000000000000002048
    (зверніть увагу на останні чотири цифри).
Об'єкти

  • В ключах {
    "C3A9:"NFC"
    ,
    "65CC81":"NFD"
    } відображено NFC — і NFD-представлення символу "é". Більшість парсерів видають два ключа, за винятком Apple JSONSerialization і Freddy, чиї словники спочатку нормалізують ключі перед тестуванням їх еквівалентності.
  • {"a":1,"a":2}
    зазвичай парс
    {"a":2}
    (Freddy, SBJSON, Go, Python, JavaScript, Ruby, Rust, Lua dksjon), але може вийти і
    {"a":1}
    (Obj-C Apple NSJSONSerialization, Swift Apple JSONSerialization, Swift Freddy) або
    {"a":1,"a":2}
    (cJSON, R, Lua JSON).
  • {"a":1,"a":1}
    зазвичай парс
    {"a":1}
    , але в cJSON, R і Lua JSON виходить
    {"a":1,"a":1}
    .
  • {"a":0,"a":-0}
    зазвичай парс
    {"a":0}
    , але може вийти
    {"a":-0}
    (Obj-C JSONKit, Go, JavaScript, Lua) або навіть
    {"a":0, "a":0}
    (cJSON, R).
Рядка

  • ["A\u0000B"]
    містить u-екрановану форму символу
    0x00 NUL
    , що може викликати проблеми в C-парсерах. Більшість парсерів обробляють цю корисну навантаження акуратно (gracefully), але JSONKit і cJSON її не парсят. Цікаво, що Freddy видає тільки
    ["A"]
    (рядок закінчується після неэкранированного байта 0x00).
  • ["\uD800"]
    це u-екранована форма
    U+D800
    , неправильного одиночного сурогату в кодуванні UTF-16. Багато парсери видають помилку, незважаючи на повну відповідність цієї строкової граматиці JSON. Python залишає її недоторканою і видає
    ["\uD800"]
    . Go і JavaScript замінюють цей образливий символ на "�"
    U+FFFD REPLACEMENT CHARACTER ["EFBFBD"]
    , R rjson і Lua dkjson просто переводять кодову крапку в її UTF-8 уявлення
    ["EDA080"]
    . R jsonlite і Lua JSON 20160728.17 замінюють кодову точку знаком питання
    ["?"]
    .
  • ["EDA080"]
    це неекранована форма
    U+D800
    , помилковий одиночний сурогат у кодуванні UTF-16, обговорений в попередньому пункті. Цей рядок не є валідним UTF-8 і повинна бути відхилена (див. розділ 2.5. Рядки — Звичайні не Unicode-символи). Але на практиці деякі парсери, наприклад cJSON, R rjson і jsonlite, Lua JSON, Lua dkjson і Ruby, залишають її недоторканою
    ["EDA080"]
    . Go і JavaScript видають
    ["EFBFBDEFBFBDEFBFBD"]
    , це три заміни символу (по одному на байт). Python 2 перетворює послідовність у Unicode-екрановану форму
    ["\ud800"]
    , а Python 3 кидає виняток
    UnicodeDecodeError
    .
  • ["\uD800\uD800"]
    зводить деякі парсери з розуму. R jsonlite видає
    ["\U00010000"]
    , а Ruby-парсер —
    ["F0908080"]
    .
6. STJSON
STJSON — це JSON-парсер, написаний на Swift 3 і складається з 600+ рядків. Я написав його, щоб з'ясувати, як можна уникнути підводних каменів і пройти всі тести.

github.com/nst/STJSON

STJSON API дуже простий:

var p = STJSONParser(data: data)

do {
let o = try p.parse()
print(o)
} catch let e {
print(e)
}

STJSON може инстанцироваться з додатковими параметрами:

var p = STJSON(data data,
maxParserDepth:1024,
options:[.useUnicodeReplacementCharacter])

Цей парсер не пройшов лише один тест:
y_string_utf16.json
. Справа в тому, що, як і майже всі інші парсери, STJSON не підтримує UTF-8 кодування, хоча їх не дуже важко додати, і, якщо знадобиться, в майбутньому я можу це зробити. Також STJSON видає відповідні помилки, коли файл починається з позначки порядку байтів в кодуванні UTF-16 або UTF-32.

7. Висновок
JSON — це не той формат даних, на який можна сліпо покладатися. Я довів це тим, що:

  • стандартне визначення розкидано як мінімум з шести різним документам (розділ 1;
  • останній і найповніший документ, RFC-7159, неточний і суперечливийрозділ 2;
  • більш ніж серед 30 парсерів, обработавших створені мною тестові файли, не знайшлося навіть двох, які б видали однакові результати (розділ 4).
Аналізуючи результати тестування, я виявив, що json_checker.c з сайту json.org відхиляє валідний JSON
[0e1]
(розділ 4.24), що ніяк не допоможе користувачам зрозуміти, де правильно, а де неправильно. Багато авторів парсерів (включаючи і мене) люблять хвалитися коректністю роботи своїх парсерів, толку від цього мало, тому що еталони спірні, а існуючі тестові набори відверто слабкі.

Я написав ще один JSON-парсер (розділ 6), який парсити або відкидає JSON-документ згідно моєму розумінню RFC 7159. Коментуйте, повідомляйте про баги і робіть pull request'и.

Цю роботу можна продовжити:

  • Документуючи поведінка багатьох інших парсерів, особливо тих, що працюють у Apple-середовищах, наприклад Json.Net.
  • Досліджуючи генерування JSON. Я докладно розглянув, що парс, а що ні (розділ 4). Коротко розглянув контент, який видається парсерами в результаті успішної роботирозділ 5). Впевнений, що якісь парсери генерують граматично неправильна JSON або навіть падають при певних обставинах (див. розділ 4.2.1).
  • Досліджуючи відмінності у способах, якими JSON-перетворювачі мапят JSON-контент в об'єкт-моделі.
  • Знаходячи експлойти в існуючих програмних стеках (див. мою презентацію Unicode Hacks).
  • Досліджуючи потенційні проблеми несумісності у інших форматах серіалізації, наприклад YAML, BSON або ProtoBuf, які можуть бути потенційними послідовниками JSON. Apple вже зробила Swift-реалізацію github.com/apple/swift-protobuf-plugin.
Я досі дивуюся, чому «крихкі» формати начебто HTML, CSS і JSON і «небезпечні» мови начебто PHP JavaScript або стали так популярні. Напевно, причина в тому, що вони дозволяють легко почати, допрацьовуючи одержуваний контент у текстовому редакторі, через надто ліберальних парсерів і інтерпретаторів, а також оманливо простих специфікацій. Але інколи прості специфікації означають приховану складність.

8. Додаток
  1. Результати парсинга seriot.ch/json/parsing.html, згенеровано автоматично розділу 4.
  2. Результати перетворення seriot.ch/json/transform.html, зроблено вручну розділу 6.
  3. Тестовий набір для JSON github.com/nst/JSONTestSuite, містить всі тести і код.
  4. STJSON github.com/nst/STJSON, мій парсер, написаний на Swift 3.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.