javascript

Ultimatum — очередной форк chromium-а или сказ о том как я кеши приручал

  • понедельник, 9 сентября 2024 г. в 00:00:08
https://habr.com/ru/articles/841658/

image


Добрый день! Меня зовут Тимур и я программист.


Сегодня я предлагаю рассмотреть очередную мою работу. Я пробросил в js прямой доступ к кешам chromium-a и теперь ими можно манипулировать из расширений браузера (при наличии соотв. permissions). Если вас не интересуют кресты а хочется халяльного js кода — переходите сразу в конец статьи, там описано реализованное api и как им пользоваться.


Итак, о чем речь и зачем это нужно?


В первую очередь это первый шаг к построению антидетект браузера, а на основе него — полноценного народного браузера который уже перестанет быть "очередным поделием на базе хромиума"©


Началось все с того что я добавил поддержку urn в chromium что является не полноценным web3.0 конечно но как минимум рабочим плацдармом для экспериментов в этом направлении, по сути я в одну рожицу выкатил front-end того что делают в IPFS, об этом можно почитать в предыдущих моих статьях. Но народ поддержал это дело вяло, я тоже особо не раскручивал, да и не маркетолог я, мне код писать как то интереснее.


Так вот, когда стало очевидно что чепчиков брошенных в воздух не будет я решил что надо найти что-то более интересное и продвигать идеи web3.0 параллельно с чем-то более востребованным. А антидетект браузер вполне себе востребованная вещь и посмотрев на то что есть на рынке как-то подумалось что многое можно сделать лучше.


После анализа трекеров которые есть в свободном доступе стало понятно что механизмов отслеживания пользователей ровно два, из которых рабочий ровно один.


Все варианты трекинга пользователя сводятся к:


  • определить модель/сборку браузера (то докуда можно дотянуться из js — useragent, косвенные fingerprints железа, платформы и прочее)
  • присвоить пользователю id и записать его в каком либо укромном месте.

Есть и промежуточные моменты, например определение набора фонтов можно рассматривать и как определение сборки/модели, и в то же время — если набор шрифтов достаточно уникален — он может использоваться как id пользователя. На эту тему статей немеряно, гугль в помощь, предлагаю на терминологию не загоняться, статья и так весьма объемная получается. Кто не в теме но хочет погрузиться — начните гуглить по supercookies а там втянетесь.


Собственно по этим двум направлениям я и пошел работать. Для пробы пера я написал код который позволяет подменять пару параметров в объекте навигатор, вот этот коммит webextensions: profiles api. Там все достаточно просто. Можно зайти в настройки хромиума в новый раздел Profile и увидеть все параметры которые подменяются. Либо собрать webextension с permission profiles (в конце статьи есть ссылка) и менять при помощи api, оно совсем простенькое:


chrome.profiles.setParameter(param_name, param_value);
chrome.profiles.setParameterSubstitution(param_name, boolean);

При помощи setParameter мы задаем значение, при помощи setParameterSubstitution включаем/выключаем подмену параметра.


param_name может принимать значения:


  • "navigator.userAgent" — собственно userAgent в navigator, но на самом деле подменяется везде где используется userAgent, в том числе и в сетевых запросах.
  • "navigator.productSub"
  • "navigator.platform"
  • "navigator.vendor"

После смены userAgent таб надо закрыть и открыть по новой что бы изменения вступили в силу (пока что я рассматриваю это как баг и намерен исправить). Все остальные параметры меняются на лету.


Собственно на этом все с этим апи, еще раз — это не что-то готовое к употреблению, это всего лишь демонстрация возможностей. С удовольствием послушаю мнения по этому поводу и пожелания на тему — поддержку подмены каких параметров стоит добавить и почему.


А теперь предлагаю перейти к прямому доступу к кешам (и особенно http-кеш).


Http cache


Почему это так важно? Потому что любой кеш, который дает возможность читать из него (пусть даже косвенно) НЕ меняя его состояния и писать в него, годится на роль запоминающего устройства для трекинга. И если мы хотим сделать антидетект браузер то просто стирать кеш недостаточно хорошо. Нужно иметь возможность запоминать его (то есть читать напрямую), стирать все что мы хотим скрыть и писать в него (то есть подменять параметры трекинга и притворяться пользователем с совершенно другой историей)


Что значит косвенно читать не меняя состояния? Одна из наиболее распространенных техник трекинга заключается в том что мы сначала говорим серверу что будем проверять пользователя, и на запросы определенного вида нужно отдавать 404. После этого запрашиваем какие то ресурсы (например 20 картинок) зная что на все запросы сервер отдаст 404 И таким образом ответы не попадут в кеш и не изменят его состояние. Если мы получили какие то ответы — значит этот пользователь уже был на сайте и ответы на запросы у него есть в кеше. По тому на какие именно запросы мы получили ответы — мы можем составить идентификатор пользователя.
Если ничего не получилось — значит пользователь новый. В этом случае говорим серверу что будем назначать пользователю id и сервер понимает что теперь на полученные запросы надо по настоящему что-то отдавать. Генерим id и на каждую бинарную 1 создаем соотв. запрос, например картинки 1x1 пиксел. Все это проливается в кеш и в следующий раз мы пользователя сможем опознать.


На самой технике в подробностях останавливаться не буду, статей в интернете хватает, попадались даже на русском достаточно приемлемого качества.


Эта техника работает с любым кешем с которым можно провернуть операцию чтения БЕЗ одновременной записи в него (этап когда сервер отдает 404 в случае с http кешем). И если http кеш мы можем хотя бы сбросить то с favicon-cache это уже не так просто. А еще есть hsts который манипулируется по тому же принципу. Не говоря о том что штатными средствами браузера подменить кеши невозможно ergo нельзя притвориться другим пользователем.


Штош, решил я. Если не я то кто и пошел читать сорцы. В принципе о существовании disk_cache я знал еще с эпизода с #Net, он тогда активно попадался мне в url_request коде, поэтому собственно и начал с него. Так вот, disk_cache как выяснилось используется весьма активно в chromium, как минимум (помимо http кеша) — в хранении js_code, wasm_code и webui_js_code (это список кешей доступ к которым я уже пробросил) Так же мне попадался код работающий с графикой и там тоже какой-то свой кеш есть на базе disk_cache но я его не разматывал. В общем тут так же как и с параметрами подстановки — если нужен какой-то кеш которого нет в списке — говорите, обсудим, если возможно — добавлю.


Собственно архитектура решения выглядела примерно так:


  • добавить новый permission (я назвал его diskCache) — что бы возможность напрямую обращаться к кешу была только у расширений запросивших такие права
  • описать api — что бы в js появились соотв. функции
  • найти код который решает где именно лежат файлы кешей и научить его отдавать эти пути по запросу
  • написать код который, получив путь до кеша, будет дергать disk_cache, поднимать инстанс и предоставлять интерфейс для работы с кешем

Немного заморочисто но не то что бы что-то недосягаемое. Вот сразу ссылка на коммит, что бы легче было читать статью.


Добавляем permission


Добавить новый permission на самом деле совсем простенько:


  • в файлы chrome/common/extensions/api/_api_features.json и chrome/common/extensions/api/_permission_features.json добавляем упоминания о том что у нас новый permission (по образу и подобию того что там уже есть)
  • в extensions/common/mojom/api_permission_id.mojom декларируем новый permission и назначаем ему номер.

Собственно все. Этих изменений достаточно что бы хромиум собрался и узнал расширение у которого в манифесте есть permission diskCache.


Но ничего больше эта сборка не делает, а нам надо что бы api появлялось в js (хотя бы тупые заглушки с тупым выводом в консоль)


Добавляем idl


Для описания сущностей в js используется idl (я касался этой темы в предыдущих статьях), документация на самом деле довольно неплохая, ссылки внизу ну и плюс всегда можно сходить код посмотреть в местах где кучкуются idl, это у нас как минимум:


  • chrome/common/extensions/api/
  • third_party/blink/renderer/core/

Возможно есть еще места скопления idl но мне не попадалось и я не углублялся.


Первая тусовка — это то где сидят описания api для webextensions, вторая — все сущности что доступны для обычного js. Нам естественно в первую дверь.


Идем туда и видим там кучку знакомых лиц, как например downloads.idl, history.json и прочие знакомые и полузнакомые лица. Какие то интерфейсы описаны в виде idl, какие то — в виде json, в документации есть описание (начать разматывать можно с chrome/common/extensions/api/_features.md), я в детали не вдавался, idl по всем признакам хватало для того что мне было нужно.


Затягивать и расписывать как я методом проб, ошибок и литров кофе нашел нужное решение не буду, вот итоговый файлик, он совсем простенький — disk_cache.idl (я не вставляю сюда код потому что тогда статья совсем тяжелая выйдет)


Сразу видим директиву [permissions=diskCache] — именно она и указывает на то что все что описано в этом файлике будет доступно только расширению с указанным permission.


Дальше идут описания структур данных которые используются в api, мы к ним вернемся когда дойдем до disk_cache. А в конце — собственно наше api, 4 функции которые нам нужны для работы с кешем:


    static void keys(DOMString storage, DiskCacheKeysCallback callback);
    static void getEntry(DOMString storage, DOMString key, DiskCacheGetEntryCallback callback);
    static void putEntry(DOMString storage, CacheRawEntry entry, DiskCachePutEntryCallback callback);
    static void deleteEntry(DOMString storage, DOMString key, DiskCacheDeleteEntryCallback callback);

Обратите внимание на DOMString — несмотря на то что код у нас будет лежать в chrome/browser/extensions/api — он по сути является частью blink, а у blink свой тип для строк.


Для каждой функции мы описываем callback — для расширений (пока) так принято, функции в api расширений не возвращают значение а последним параметром принимают callback в который и будет пробрасываться результат исполнения. Но при этом на основании этого описания сгенерится код который позволит вызывать (из js) функцию асинхронно и в этом случае будет возвращаться промис который будет резолвиться в то что этому callback-у выдали. На этом этапе я даже не пытался включать мозг, тут так принято и это гуглевые заморочки. Очевидно что ответ на этот вопрос (почему так) — есть, и скорее всего он спрятан где-то в недрах генератора кода mojo или парсера idl, в общем где-то куда я не хочу и не уверен что мне там рады.


So far so good… Но одного idl файла недостаточно что бы наша сборка завелась. Во первых что бы ninja его увидел нам надо этот файлик прописать в сборке, grep-аем по именам известных api и находим в окрестностях chrome/common/extensions/api/api_sources.gni, прописываем там наш disk_cache.idl.
А во вторых нам теперь нужна реализация описанного интерфейса. Да, на основании idl файла у нас сгенерится код обвязки, помогающий интегрироваться в chromium, но на то он и код обвязки что бы обвязывать что-то. Опять же, походив по директориям видим что код реализации webextensions интерфейсов лежит в chrome/browser/extensions/api.


Что же, создаем тут директорию, назовем ее disk_cache и в ней — .cc и .h файлы, пусть будет disk_cache_api.*. Названия файлов имеют значение но некритично, а вот к содержимому требования есть. Поскольку мы в idl файле обозначили namespace diskCache то (если не указано другое) сборка будет ожидать директорию disk_cache в chrome/browser/extensions/api/ с файлами disk_cache_api.*. Я это вывел эмпирически, глядя на сорцы но уверен что в документации это где-то есть. На это поведение можно повлиять указав явно файл реализации, как это сделано например в chrome/common/extensions/api/document_scan.idl при помощи директивыimplemented_in. Но нам это не надо, мы играем по правилам так что движемся дальше.


А дальше на каждую функцию объявленную в idl сборка будет ожидать класс с именем DiskCache[FunctionName]Function, у которого должен быть объявлен метод Run, который собственно и будет дергаться когда мы в js вызовем нашу функцию новоиспеченного api. А поскольку мы объявляем 4 новых функции то нам надо будет 4 новых класса. Объявляем их и для начала делаем std::cout просто что бы понять что конструкция работает.
В .h описываем наши классы, из интересного только отмечу вот такую конструкцию:


DECLARE_EXTENSION_FUNCTION("diskCache.getEntry", DISKCACHE_GETENTRY)

Очевидно какой-то шаблон который нужен для того что наш код куда то попал и там куда он попадет знали что с ним делать. Тут я тоже голову не включал, просто посмотрел как у соседей сделано. Но при этом константу которую мы передаем параметром (DISKCACHE_GETENTRY в этом случае) нужно объявлять, причем в двух местах, а именно:


  • extensions/browser/extension_function_histogram_value.h — тут задаем последнее неиспользованное значение
  • tools/metrics/histograms/metadata/extensions/enums.xml — задаем тоже значение что и в предыдущем файле

Теперь знакомим сборку с новыми файлами, это делается в chrome/browser/extensions/BUILD.gn.


Насколько я помню — этого достаточно что бы сборка завелась. Теперь у нас есть chromium который понимает diskCache permission и если мы установим расширение с таким пермишеном, то в консоли расширения (и в самом коде расширения) можно делать вызов функций:


chrome.diskCache.keys()
chrome.diskCache.getEntry()
chrome.diskCache.putEntry()
chrome.diskCache.deleteEntry()

Неплохо. Но вызовы ничего не делают (и вполне возможно будут падать если не передать параметры). Надо бы вдохнуть жизнь в эти безжизненные конструкции.


Но не так быстро. Тут давайте на минутку отвлечемся от кода. Да, это было весело и задорно — накидывать файлики, прописывать их где-то там и получать быстрый результат где-то тут. Дело в том что делали то мы довольно шаблонные вещи, то в чем можно ориентироваться без документации, просто поглядывая на соседний код. Но так можно далеко не всегда, иногда наступает момент когда приходится включать мозг, начать думать, и самое страшное — читать документацию, что бы не гадать что делать дальше а знать как оно устроено и куда нам надо залезть своими неуемными ручонками что бы воплотить все великолепие наших фантазий. Так вот, этот момент настал.


Размышляем


Чтение кеша это файловая операция, то есть I/O, а для I/O в chromium-е выделен отдельный тред в процессе браузера (хромиум у нас многопроцессный, на эту тему тоже есть дока, ссылки внизу) а лезть мы туда будем из render-thread-а процесса рендерера. А общение между процессами у нас происходит при помощи сообщений, и для этого нам придется раскурить mojo. Если вам попадется статья про IPC — Inter-process Communication (IPC) проходите мимо, это устаревшая дока и полезной она может быть только если вы разматываете какое то legacy (не уверен насколько это актуально, но в доках мне попадалось упоминание о том что какой-то код в chromium еще использует IPC). Весь порядочный код сейчас использует mojo.


Так вот, похоже нам как минимум придется найти место куда положить код который будет заниматься I/O, раскидать формат сообщений которыми мы с ним будем общаться, и научиться достукиваться до него из blink-а.


Естественно самое простое — это посмотреть как это сделано до нас, и конкретно в этом случае нам повезло — есть куда смотреть. У нас есть замечательный api CacheStorage который делает практически тоже самое что нужно и нам. Он также использует disk_cache, отличие заключается в том что во первых CacheStorage является обычным глобалом и доступен всем, во вторых он пишет в свой собственный кеш который не пересекается с http-кешем, и пишет он bucket-ами. Когда мы делаем CacheStorage.open(cacheName) то вот этот cacheName на самом деле и есть имя bucket-а с которым мы собираемся работать. Собственно отличия не критичные, CacheStorage больше похож на то что нам надо чем не похож, так что как отправная точка выглядит вполне себе.


Ок, решил я и пошел курить CacheStorage.


Курим CacheStorage


Расположен он в следующих местах:


  • content/browser/cache_storage/ — сама реализация сервиса, то что работает с disk_cache и при этом принимает/отправляет сообщения из других процессов
  • third_party/blink/common/cache_storage/ — один файлик с одной функцией, не заинтриговало
  • third_party/blink/public/common/cache_storage — продолжение предыдущего пункта, тоже мимо
  • third_party/blink/public/mojom/cache_storage/cache_storage.mojom — вот, это интересно, тут описан формат сообщений для обмена, нам такое тоже нужно будет
  • third_party/blink/renderer/modules/cache_storage/ — собственно реализация фронта cacheStorage api, то есть то что видно из js. Мы будем сюда поглядывать, но с учетом того что мы пишем для расширений скорее всего не все решения из этого кода будут применимы.

В первую очередь я отправился изучать content/browser/cache_storage/ — там есть readme в котором неплохо все расписано, в том числе указано кто отвечает за диспетчеризацию сообщений. Соответственно я стал просто копировать код и перебивать его классы с CacheStorage[something-something] на CacheSorageRaw[something-something], все это делалось в директории cache_storage_raw заботливо созданной мною рядом с content/browser/cache_storage/. Естественно я копировал не все подряд а для начала только ту часть которая отвечала за прием сообщений, то есть идея была наладить сначала логистику а потом уже накидывать на нее мясо. При этом я держал в голове тот факт что bucket-ов у нас нет, только прямой доступ к кешам без всяких надстроек, так что соответствующий код я просто удалял.


Таким образом я создал:


  • cache_storage_raw_dispatcher_host.*
  • cache_storage_raw_control_wrapper.*
  • cache_storage_raw_context_impl.*
  • cache_storage_raw.* — тут и должна будет лежать реальная логика отработки запроса

Все отсылки к классам и прочим сущностям описанным в других файлах я просто закомментил или удалил, на этом этапе все это выглядело как что-то лишнее.


Добавил эти файлы в сборку прописав их в content/browser/BUILD.gn и, исправляя свои же опечатки и удаляя лишний код (то есть любой вызывающий ошибку сборки) стал добиваться того что бы оно собралось. Да, знаю, звучит дико, но на самом деле работает (для меня). Конечно есть люди которые придут и расскажут как это надо было делать на самом деле и покажут свои произведения искусства программирования а мы восторженно похлопаем в ладоши, а пока что — что есть тому и радуемся.


Естественно оно в итоге не завелось, по той простой причине что в коде cache_storage есть отсылки к mojom классам (mojom в этом случае не технология а namespace) и тупое переименование типа с blink::mojom::CacheStorage на blink::mojom::CacheStorageRaw нам ничего не даст — blink::mojom::CacheStorageRaw сам по себе из воздуха не появится. Да, настал тот самый фатально неизбежный момент который все мы ненавидим всеми фибрами наших душ — надо думать и читать документацию.


Налаживаем логистику


На самом деле не все так плохо — у нас по крайней мере есть что читать. Читать документацию при отсутствии оной было бы немного сложнее.


Что же, грызем доки по mojo, поглядываем на пример в документации с подъемом своего сервиса и возвращаемся. Помните я в списке файлов имеющих отношение к cacheStorage приводил вот этот:


  • third_party/blink/public/mojom/cache_storage/cache_storage.mojom

Собственно все что нам нужно это описать что-то вроде него но для нас. Итоговый вариант можно посмотреть тут cache_storage_raw.mojom Можно заметить импорт из disk_cache_raw_api.mojom — это структуры данных, используемые при обмене, я вернусь к этому позже и объясню почему они лежат там где лежат. А пока что нам достаточно того что мы описали 4 сообщения на каждую функцию из api и в принципе готовы налаживать логистику.


cache_storage_raw.mojom нужно также добавить в сборку, это мы делаем в third_party/blink/public/mojom/BUILD.gn (ну и соотвественно в services/network/public/mojom/BUILD.gn прописываем disk_cache_raw_api.mojom раз уж мы его упомянули).


Ок. Вот теперь у нас все соберется (на самом деле нет — мне пришлось отключить все что касалось control-a, но об этом ниже). Оно все еще нерабочее, но хотя бы собирается. Прежде чем перейти к налаживанию логистики сообщений давайте бегло пробежимся по тому что происходит под капотом.


На каждый mojom файл у нас генерится соответствующий с++ код. На самом деле еще и js код и java, но поскольку они нам не нужны я их не рассматриваю. Для idl файлов кстати происходит примерно то же самое. Сгенерированный код для любого такого файла лежащего по пути path_to_file очень легко посмотреть если сделать vim [директория сборки, например out/Default]/gen/[path_to_file].[cc|h] Это стоит запомнить, в самой статье я этого не касаюсь но если будете ваять что-то сами — заглядывать в сгенерированный код придется часто. Зачастую легче заглянуть в код что бы посмотреть в какие типы сгенерилось сообщение чем искать в документации таблицу соответствия — это самый частый но не единственный кейс.


Генерация кода происходит до того как ninja перейдет к компиляции, так что в своем коде можно и нужно спокойно импортировать сгенеренный код, указывая путь до idl/mojom файла и меняя расширение на .h. При этом out/Default/gen указывать естественно не надо, ninja сам разберется куда надо смотреть.


Второй момент который хотелось бы напомнить — mojom файлы помогают описывать интерфейсы а не только данные, то есть при помощи сообщений в с++ коде можно передать не только данные но и указатели на код, именно так пробрасываются коллбэки, при этом этот код будет исполнен на стороне получателя сообщения. Об этом тоже стоит помнить и если мы работаем с классом методы которого могут выполняться в разных процессах и это критично в каком процессе мы сейчас находимся — то да, это придется проверять (можно грепнуть по DCHECK_CALLED_ON_VALID_SEQUENCE тот же content/browser/cache_storage/ и посмотреть как там это сделано).


Итак. У нас есть:


  • работающий permission для webextensions — diskCache
  • полуживое api в js, которое можно потыкать но оно не шевелится (в chrome/browser/extensions/api/disk_cache)
  • полуживой код в content/browser/cache_storage_raw/ до которого можно (в теории) достучаться и который (предположительно) и будет делать всю работу с кешами
  • набор mojo сообщений с помощью которых мы по идее должны суметь из кода в chrome/browser/extensions/api/disk_cache (который у нас теперь часть blink-a) достучаться до кода в content/browser/cache_storage_raw/ (который у нас часть I/O процесса но это не точно)

Что же, давайте теперь познакомим эти два кода друг с другом.


В документации (Intro to Mojo & Services) этот процесс расписан довольно подробно, единственная наша заморочка касается того что мы в webextension, более того, мы в manifest v3 webextension (второй манифест гугль в любом случае рано или поздно выпилит, зачем себя обрекать на боль), а значит бэк нашего расширения в котором мы хотим воспользоваться новым api будет serviceworker-ом, что добавляет красок в этот серый пейзаж.


Для начала я начал смотреть докуда мы можем дотянуться из контекста webextension. В функциях расширения мы имеем доступ к content::BrowserContext из которого можно дотянуться много до чего интересного. Но в первую очередь я решил сходить в StoragePartition и оказался прав. StoragePartition мы можем получить цепочкой вызовов:


Profile::FromBrowserContext(browser_context())->GetDefaultStoragePartition();

Заглянув в content/browser/storage_partition_impl.h и побродив по окрестностям можно заметить метод storage::mojom::CacheStorageControl* GetCacheStorageControl() override;. Да, это тот самый контрол который я отключал на предыдущем шаге.


Уже интересно. Выглядит как некая панель управления для CacheStorage.


Размотав цепочки вызовов находим components/services/storage/public/mojom/cache_storage_control.mojom, заглядываем в него и среди информационного шума видим метод AddReceiver сигнатура которого содержит параметр pending_receiver<blink.mojom.CacheStorage> receiver. Тем кто потратил свое время на изучение доки становится ясно насколько близко к телу мы подобрались. В доке описано как регистрировать сервисы через RenderFrameHostImpl но это не единственный путь, там ниже упоминаются и другие точки подключения, в том числе и RenderProcessHostImpl. Сходив в content/browser/renderer_host/render_process_host_impl.cc видим там RenderProcessHostImpl::BindCacheStorage.


Ага, тут понятно, мы влепим по образу и подобию RenderProcessHostImpl::BindCacheStorageRaw, в коде chrome/browser/extensions/api/disk_cache инициализируем message pipe по документации, только в итоге завершать инициализацию будем вот так:


  storage::mojom::CacheStorageRawControl* control = GetProfile()->GetDefaultStoragePartition()->GetCacheStorageRawControl();
  mojo::PendingRemote<blink::mojom::CacheStorageRaw> remote;
  control->AddReceiver(remote.InitWithNewPipeAndPassReceiver());
  cache_storage_.Bind(std::move(remote));

А в коде content/browser/cache_storage_raw/ расскоментим все что мы наваяли по поводу control-а, переосмыслим и перебьем под свои нужды.


А дальше все не так уж и сложно, я прошелся по цепочке вызовов cacheStorage, размотал, осознал, выкинул лишнее и сделал такое же но для cacheStorageRaw. Задействованные файлы:


  • content/browser/browser_interface_binders.cc
  • content/browser/mojo_binder_policy_map_impl.cc
  • content/browser/renderer_host/render_process_host_impl.cc
  • content/browser/renderer_host/render_process_host_impl.h
  • content/browser/service_worker/embedded_worker_instance.cc — потому что он используется всеми реализациями worker-ов в chromium
  • content/browser/service_worker/embedded_worker_instance.h
  • content/browser/service_worker/service_worker_host.h
  • content/browser/storage_partition_impl.cc
  • content/browser/storage_partition_impl.h
  • content/public/browser/render_process_host.h
  • content/public/browser/storage_partition.h
  • components/services/storage/public/mojom/BUILD.gn
  • components/services/storage/public/mojom/cache_storage_raw_control.mojom — по образу и подобию, упрощенный по максимуму

Собственно этой пары строк кода оказалось достаточно чтобы сообщение из blink-a прилетело туда где мы положили код cache_storage_raw. Очень удобно, все для программиста, только программировай!


Собрав всю эту богадельню вместе, я как тот пилот из анекдота попытался взлететь и в общем-то с нанадцатой попытки у меня получилось. Вызов функции из js приводил к тому что отправлялось сообщение, создавался инстанс CacheStorageRaw и в нем дергался метод соответствующий вызванной функции (пока что все сводилось к выводу в std::cout, но было видно что работает)


Оставалось только найти где у нас лежат кеши и написать код который будет создавать их инстансы и с ними работать.


Добываем пути до кешей.


Там же, в StoragePartition есть метод GetGeneratedCodeCacheContext возвращающий GeneratedCodeCacheContext который владеет путями до кешей js_code, wasm_code и webui_js_code. Немного правим код и вот уже в GeneratedCodeCacheContext есть методы generated_js_code_cache_path, generated_wasm_code_cache_path и generated_webui_js_code_cache_path. Ок, эти пути мы можем вытащить сразу в chrome/browser/extensions/api/disk_cache/disk_cache_api.cc и добавлять путь к кешу с которым мы хотим работать сразу в сообщение.


С http кешем картина немного иная. В итоговом коде определения пути к нему вообще нет, почему так — расскажу ниже. А вот в первой моей версии такой код был, я для этого лез в NetworkContext и добавлял там для этого метод. Сидя в NetworkContext путь до http кеша можно добыть так:


params_->file_paths->http_cache_directory->path()

а сам NetworkContext в chrome/browser/extensions/api/disk_cache/disk_cache_api.cc можно добыть через StoragePartition:


Profile::FromBrowserContext(browser_context())->GetDefaultStoragePartition()-> GetNetworkContext();

При этом имейте в виду что если будете править NetworkContext то сначала надо изменения внести в services/network/public/mojom/network_context.mojom и соотв. все общение с ним (из blink-а) будет происходить асинхронно через сообщения. Ничего страшного, это мы тоже делаем, ниже есть разбор и этого действа.


А пока что имеем возможность получать пути до кешей, нам для этого пришлось потревожить файлы:


  • content/browser/code_cache/generated_code_cache_context.cc
  • content/browser/code_cache/generated_code_cache_context.h

По сути отделались легким испугом.


И вот они мы уже на пороге disk_cache, нетерпеливо потираем потные ручонки.


Приводим disk_cache к вменяемому виду


Код disk_cache располагается в net/disk_cache/ и активно пользуется в первую очередь url_request-ом. Естественно я изучил его прежде чем вообще начать делать все то что я описываю, особенно на тему пригодности его к использованию с api которое мы впиливаем. Так вот, пригоден он довольно слабенько. Читать какое либо вхождение или удалять его из кеша — да, действительно несложно, если знать под каким ключом оно лежит. А вот метода который давал бы нам список ключей там не предусмотрено, добывать их придется самим.


Как так, спросите вы. На самом деле это вполне логично если вспомнить как работает кеш. Вы сталкиваетесь с какой-то сущностью которую вам надо добыть (запрос в сеть, кусок скомпилированного кода, etc) и спрашиваете у кеша нет ли у него такого. То есть у вас на руках уже есть какая то информация о том что вам надо. Вот на основе этой информации и строится ключ под которым это все будет хранится. То есть предполагается что вы знаете что вам нужно когда идете в кеш. А мы в нашей ситуации не знаем что нам нужно и как гопники предлагаем ему вывернуть карманы и показать содержимое. А он этого не умеет и его этому нужно еще научить.


Второй похожий момент связан со sparse entities. Это когда хранится не весь ресурс целиком а только какие то его части, то есть в содержимом могут быть дырки. В этом случае обращение в кеш идет с указанием range, что-то вроде а нет ли у тебя кусочка с 2047 по 3096 байта от контента X. Если есть — кеш отдаст, если нет — скажет что нет. Но вот получить список кусочков которые у него есть — штатными средствами не получится, тут тоже придется лезть под капот.


Помимо двух озвученных помех имеем на руках тот факт что реализаций у нас две — simple cache и blockfile, в документации сказано о том что последняя используется под windows. Я отметил себе это но поскольку рабочий ноут у меня mac решил к этому вернуться уже после того как получу вменяемый результат.


Но прежде чем потрошить содержимое disk_cache давайте раскидаем структуры данных. По документации вполне понятно с чем мы имеем дело и мы можем уже заложиться на это прямо сейчас. Каждое вхождение имеет ключ под которым оно хранится, это обычная строка. Далее — мы имеем два вида вхождений:


  • обычные (сплошные) для которых можно использовать до трех каналов данных, то есть до трех буферов
  • sparse entities, у них есть один обычный буфер, второй — который хранит чанки, и к этому нам надо будет еще сохранять информацию о чанках (смещение и длину)

Помните в начале статьи я вам говорил про disk_cache_raw_api.mojom и обещал что мы к нему еще вернемся? Так вот, мы вернулись и разбираем его содержимое. А почему он лежит там где он лежит я расскажу попозже.


В этом файле описан формат как самого cache entry который мы только что разобрали так и ответы которые прилетят на запросы keys и getEntry. Для функций deleteEntry и putEntry мы ничего нового не добавляем потому что:


  • deleteEntry на входе получает две строки — имя кеша (на базе которого мы выведем путь) и ключ — никаких новых типов нам для этого не надо
  • putEntry на входе (помимо имени кеша и ключа) получает cache entry, а этот тип мы уже описали
  • deleteEntry и putEntry возвращают только статус выполнения операции или сообщение об ошибке, обычной строки для этого достаточно.

Но disk_cache_raw_api.mojom не единственное место где нам придется описать эти структуры. У нас есть еще idl файл, помните? Который описывает как это все будет выглядеть в js, disk_cache.idl. Способа подтянуть в него уже созданный mojom я не нашел, есть еще обратный вариант — подтягивать везде классы сгенеренные в blink но он меня немного смущает, мне кажется network_context и network_service не должны знать о blink, они более низкоуровневые, это blink должен знать о них.


Как бы там ни было, я описал структуры и в idl, там все достаточно понятно, на этом заострять внимание не буду.


В первую очередь я решил довести до ума получение ключей из кеша и, поскольку на маке хромиум работает только с simple cache реализацией — решил это сделать для нее. Тут никаких неожиданностей не всплыло, я посмотрел как устроен поиск по ключу в уже имеющемся коде — там тупой обход, соответственно я сделал такой же тупой обход по всем вхождениям, но не в поиске заданного ключа а аккумулируя все ключи что попадаются. Единственная интересная деталь — в коде chromium/content/browser/cache_storage есть счетчик вызовов для того что бы не переполнить стек при обходе кеша, если вложенность более 20 — цепочка вызовов прерывается и создается таска для менеджера очереди. Это можно посмотреть тут cache_storage_cache.cc и идея мне показалась настолько здравой что я ее перенес в свой код итератора при добывании ключей.


Для работы с disk_cache я создал директорию /net/disk_cache_raw/, при этом все классы описываемые в этом коде лежат в неймспейсе disk_cache. Для каждой операции (keys/getEntry/putEntry/deleteEntry) я создал соотв. класс, у каждого из них есть метод Run, с которого и начинается вся работа. Таким образом код который хочет обратиться к кешу создает экземпляр одного их указанных выше исполнителей и вызывает на нем метод Run, передавая ему параметры и коллбэк, который и получит результат выполнения операции. Для погружения в коллбэки есть прекрасная дока, OnceCallback<> and BindOnce()... и Threading and Tasks in Chrome, смысла ее пересказывать тут нет. Едиственный момент на который обращу внимание — все обращения к disk_cache могут вернуть net::ERR_IO_PENDING или какой-то другой код ошибки. Если вернулся net::ERR_IO_PENDING то операция будет выполнена асинхронно и результат будет передан коллбэку, если же вернулось что-то другое — то операция выполнена сразу и нужно отрабатывать результат. Поэтому тут мы знакомимся со base::SplitOnceCallback — он позволяет получить пару коллбэков-близнецов, один мы передаем в параметрах вызова при обращении к disk_cache, второй используем сразу в случае если результат был получен немедленно. Это можно посмотреть например тут.


Во всем остальном что касается асинхронной работы — двух упомянутых выше статей из документации вполне достаточно что бы покрыть базовые потребности.


В общем тут все было как обычно, накидал код, исправил опечатки, собралось, заработало.


Что же, получение ключей работает, давайте на нем разберем механику происходящего — все остальные функции будут работать по схожему принципу.


Осознаем проделанное


Во первых как мы указываем с каким кешем мы хотим работать? Пути до кешей отличаются от платформы к платформе, поэтому я решил что пробрасывать их в сигнатуру js функций будет не самой лучшей идеей. Исходя из этого мы указываем не путь до кеша а его имя, http,js_code, wasm_code или webui_js_code.


Когда мы делаем (в js) chrome.diskCache.keys(cacheName) то под капотом происходит следующее:


  • вызывается метод DiskCacheKeysFunction::Run


    • в нем мы проверяем параметры вызова (EXTENSION_FUNCTION_VALIDATE)
    • создаем pipe для общения с другими процессами (Init)
    • имя кеша мы резолвим в path, но параметром передаем еще и коллбэк — об этом тоже немного позже. Вся оставшаяся часть работы будет происходить в этом коллбэке, поэтому тут мы закругляемся
    • возвращаем промис RespondLater()

  • В методе GetPath мы резолвим имя в путь при помощи методов которые мы добавили в GeneratedCodeCacheContext и после этого вызываем переданный нам коллбэк вместе с полученным путем, в нашем случае коллбэком будет DiskCacheKeysFunction::OnPath


  • В DiskCacheKeysFunction::OnPath если путь пустой (не смогли разрезолвить) бросаем ошибку, если все хорошо — на созданном пайпе (cache_storage_) вызываем метод Keys с полученным путем до кеша. Этот метод мы описали в third_party/blink/public/mojom/cache_storage_raw/cache_storage_raw.mojom и реализовали в content/browser/cache_storage_raw/cache_storage_raw.cc. При помощи mojo-магии описанной выше наш метод CacheStorageRaw::Keys (который сидит уже в IO треде) получает путь до кеша и коллбэк которому надо будет потом отдать список ключей найденных в этом кеше.


  • В CacheStorageRaw::Keys создаем объект disk_cache::CacheStorageRawApiKeys и вызываем на нем Run с путем до файла и полученным коллбэком.


  • В disk_cache::CacheStorageRawApiKeys обходим кеш и собраем ключи, на этом углубляться не буду. Полученный результат передаем в коллбэк, который, как мы помним, прилетел из chrome/browser/extensions/api/disk_cache/disk_cache_api.cc и является DiskCacheKeysFunction::OnKeys


  • В DiskCacheKeysFunction::OnKeys проверяем статус ответа, если не ок — бросаем ошибку, если ок — делаем Respond, преобразовав полученный std::vector<std::string>> в то что ожидается при помощи api::disk_cache::Keys::Results::Create — это то что сгенерилось на основании нашего idl файла и мы всегда можем посмотреть туда, выше в статье упоминалось как это сделать.



Вот и все, в этот момент пытливый читатель, проделавший все эти манипуляции в консоли увидит массив со всеми ключами кеша. А владея ключами мы теперь можем запросить или удалить какое то вхождение в кеш, этот код я подробно разбирать не буду, там немного больше деталей но принципиально нового ничего нет.


Идем во вражеский стан (нам там не рады)


Довольный собой я отправился собирать это все под винду. Задача выглядела несложной, надо было просто портировать изменения в api disk_storage и на этом все. То есть мы же помним, я говорил об этом — на всех платформах используется simple cache реализация, а под виндой — simple cache в большинстве случаев но blockfile реализация для http cache.


Но тут получилось как обычно. Да, я впилил эти изменения в blockfile, это было не так сложно, но когда я собрал это все добро и запустил, любое обращение к http кешу падало с ошибкой. Доставлял еще тот момент что под виндой std::cout (и cerr) не выводятся в консоль, там похоже что любой вывод в IO треде теряется. Расчехлять дебаггер я не привык, чай не ядро линукса пишем, пришлось остановится на NOT_REACHED, но с ним плохо то что ты падаешь, а значит не можешь вытащить инфу более чем из одной точки. Не смертельно но не сказать что удобно.


Так вот. Падал код по той причине что не мог открыть файл кеша. Побродив по коду я выяснил что под виндой файл открывается монопольно, то есть файл кеша уже где то открыт и незваным гостям тут не рады. Побродив еще я выяснил кто им владеет, владельцем оказался network context. И тут стало ясно что архитектуру надо немного менять. Для всех кешей который используют simple cache — оставить как есть, а для тех что blockfile (то есть пока только http) — находить владельца и общаться через него. Помните все те обещания рассказать попозже почему там и как? Вот это тот самый момент.


Мне пришлось залезть в network_context и научить его работать к нашим api. Ничего сложного, все на том же уровне что и было, но это отразилось на коде:


Во первых мы в проверяем в chrome/browser/extensions/api/disk_cache/disk_cache_api.cc к какому кешу идет обращение и если это http то немного меняем механику работы:


  if (params->storage == "http") {
    auto* network_context = GetProfile()->GetDefaultStoragePartition()->GetNetworkContext();
    network_context->[тут вызов нужной нам функции непосредственно на network_context];
  }

С network_context мы тоже работаем находясь в разных процессах, протокол общения описан при помощи mojo, поэтому прежде чем править код самого network_context — указываем что мы хотим добавить в mojo — services/network/public/mojom/network_context.mojom. Я выше говорил о том что мы этого коснемся, в принципе чтение diff тех файлов что я сейчас упомяну достаточно что бы понять суть происходящего.


Далее в самом network_context мы создаем исполнителей запросов на disk_cache (это те классы что лежат в /net/disk_cache_raw_api/) но вместо пути до кеша передаем готовый инстанс (потому что он уже у нас есть и лежит в network_context И мы не можем создать второй такой — файл кеша заблокирован при создании первого экземпляра). Это вынудило меня поменять сигнатуры (до этого исполнители получали только пути до файлов).


Но! Тут еще одно важное изменение. Когда мы получаем путь до файла в исполнителе и сами поднимаем инстанс disk_cache — мы же ответственны за его уничтожение (memory leaks, да). Но когда мы получаем уже готовый инстанс (в виде сырого указателя) — то за уборку мусора отвечает вызывающая сторона. А поскольку в исполнителях мы это все храним в уникальных указателях — то нам еще нужно запоминать откуда взялся инстанс, снаружи или создали сами. Если снаружи — то в деструкторе исполнителя освобождаем инстанс раньше чем деструктор std::unique_ptr уничтожит объект на который он ссылается, это можно посмотреть тут. Если этого не сделать то, после первого же вызова api, network_context потеряет http cache, это немного не то чего мы хотим добиться.


Что касается кода под виндой — это все, отмечу только один интересный момент, я его еще пока что раскуриваю. Дело в том что выглядит так что реализация blockfile не умеет работать с произвольными интервалами (ranges). Там используются битмапы, и каждому биту соответствует 1KiB, битмапой владеет child, который отвечает за 1MiB. Так вот, child, судя по коду, понимает что блок в 1KiB может быть использован не весь, но только один блок. Прочитать об этом можно в самом коде вот тут.


То есть если границы двух ranges не кратны 1KiB и попадают в один child — он этого не запомнит. В каких то случаях он тупо уничтожает (doom) весь cache entity, в каких — теряет один range. То есть если я не туплю и это именно предполагаемое поведение то это способ достоверно определять винду (на других платформах используется simple cache и он умеет работать с любыми ranges). Если это действительно так — то надо будет еще впиливать имитацию ограничений для siple cache и доводить до ума blockfile что бы он умел работать с чанками по любым границам.


Закрываем гештальты


Что же. Практически все, что хотелось рассказать, осталась одна нераскрытая деталь (из обещанного). Почему описание типов лежит в disk_cache_raw_api.mojom? Так вот. У нас есть описание структур данных в блинке (для того что бы мы могли указать их в сигнатурах js функций и на выходе получить сгенеренные удобные валидаторы аргументов. И у нас есть описание в mojom. И когда мы получаем данные в blink — мы их конвертим в mojom-типы и шлем запрос. Когда получаем ответ в виде mojom-типов — конвертим назад в то что мы описали в idl файле. Если от этих конвертаций можно избавиться — подсказывайте, исправлю, я решения не нашел. А теперь к mojom. Понятно что описать их надо где то в одном месте, и уже оттуда тащить. disk_cache — слишком низкоуровневый, в доке есть прямая просьба в директорию net вообще ничего лишнего не класть. content/browser/cache_storage_raw нормальная директория для хранения если смотреть с точки зрения blink-а. Но из-за того что http кеш под виндой доступен только через NetworkContext — нам эти структуры данных нужны и в NetworkContext, а знать о content/browser/cache_storage_raw из NetworkContext уже странно. Вот и получается что services/network/public/mojom/ самая подходящая директория для хранения этого кода — тот факт что NetworkContext и blink знают о ее существовании ничью репутацию под сомнение не ставит.


На этом все с с++, напоследок давайте взглянем что мы получили в js.


Расслабляемся


А получили мы следующее api:


chrome.diskCache.keys(cache_name); // возвращает массив ключей в кеше
chrome.diskCache.getEntry(cache_name, key); // возвращает указанное вхождение в кеш
chrome.diskCache.putEntry(cache_name, entry); // пишет в указанный кеш, key указывается в entry
chrome.diskCache.deleteEntry(cache_name, key); // удаляет указанное вхождение

При этом вхождение в кеш имеет следующий формат:


{
  key: "string",
  stream0: ArrayBuffer,
  stream1: ArrayBuffer,
  stream2: ArrayBuffer,
  ranges: Array
}
// где ranges состоит из объектов:
{
  offset: number,
  length: number,
}

Свойство ranges необязательное и указывается только для sparse entities. stream0, 1, 2 обязательны для всех но для sparse entities используется только stream0 и stream1, при этом stream1 содержит все чанки идущие друг за другом (без пустот) а ranges указывают где они (чанки) должны были располагаться. То есть длина stream1 должна совпадать с суммой всех length указанных в ranges.


Для удобства я накидал каркас расширения в котором уже есть permissions profiles и diskCache, оно лежит тут, никакого кода в нем нет, можете убедиться глазами, все безопасно. Делаем:


git clone https://github.com/gonzazoid/extended_permissions_webext.git

В хромиуме (который вы собрали сами или скачали по ссылке, обычная сборка не распарсит манифест этого расширения) идем в настройках на расширения, включаем девелоперский режим и указываем директорию в которую склонировали репу. После этого можно открыть консоль расширения и в ней поиграть с api, живьем посмотрев на то как это происходит.


Там же (в настройках) можно поиграть с параметрами замены, а когда станет совсем скучно — сгенерить ключ и приобщиться к web3.0, вот тут я рассказывал как.


Ok. Почти дошли. С кодом на этом все, если вам лень собирать хромиум самим — бинарники (macos, win, ubuntu) можно скачать у меня в бложике, ниже список ресурсов по теме, теперь давайте немного отвлеченно поговорим на тему того как и где оно может применяться.


Ресурсы по теме:



Mojo



Multi-process architecture && callbacks



IDL



Фантазируем


А применяться оно может везде где применяются антидетект браузеры. Да, большая часть кейсов — серый схематоз, если не криминал, но тем не менее есть люди которым не нравится одержимость с которой продаваны следят за пользователями. А инструменты созданные и развитые бизнесом затем начинают использоваться не только бизнесом и у общества (в целом) есть запрос на антидот от этой отравы.


Самая грязная тема, в которой оно может применяться — это, на мой взгляд — ботофермы. Но я смотрю трезво на это. Тот факт что оно применяется не там где хотелось бы не должен нас останавливать от того что бы создавать инструмент который нам нужен, а он нам нужен. Нам нужен браузер который работает на нас а не на корпорации и власть, как бы это пафосно ни звучало. И можно очень долго и убедительно размышлять о том как ничего тут сделать нельзя, приводить расчеты, графики, убеждать отдельно взятых "настоящих буйных" в их никчемности и малости. А можно просто начать что то делать, что то реальное, в надежде что другие буйные подтянутся, может быть чуть попозже, но они появятся.


Что касается меня — я создал расширение (Помогатор, можете посмотреть у меня на сайте) которое позволяет сохранять слепок состояния браузера и как переключаться между ними так и обмениваться ими между пользователями. Там тоже применения разные, лукавить не буду, создавал по запросу перекупов сидящих на одной крупной торговой площадке, что бы та не могла определять что один пользователь сидит под сотней аккаунтов. Оно (расширение) все еще в развитии, пока умеет только сохранять куки и local storages, но, как говорится, все впереди и api которое мы рассмотрели — тоже пойдет в копилку скилов этого расширения, следующим шагом я намерен впилить поддержку и тогда при сохранении профиля будет запоминаться так же и содержимое кеша.


Но это все — не более чем ступеньки развития, я воспринимаю это все лишь как инструменты для достижения цели. А целью является децентрализованный web (не путать с интернетом) и надежный браузер которому можно верить. И сегодня, я уверен, что сделан очередной (хоть и маленький) шаг в этом направлении.


Следующие шаги, которые я намерен сделать в этом направлении:


  • favicon-cache
  • hsts cache
  • разобраться со шрифтами

Шаги, которые я намерен сделать для развития web3.0:


  • добавить в chromium поддержку STUN серверов
  • добавить возможность шарить свой кеш по заданным правилам (каждый браузер может стать #Net агентом)

В принципе два серьезных коммита уже есть за спиной, код пережил пару апов версий chromium-а, думаю уже можно начинать вокруг этого собирать людей и запускать краудфандинг (другие способы финансирования не рассматриваю). Если кому интересно — вливайтесь, это всерьез и надолго.


В общих чертах это все, знаю что получилось довольно поверхностно, но тут приходилось все время балансировать. Объем работы на самом деле проделан был не маленький и если вдаваться во все подробности то статья превратится в энциклопедию. Если есть какие то вопросы — задавайте, отвечу в комментариях, если видите как можно улучшить статью — говорите, давайте общаться.


А новостей на сегодня больше нет, с вами был Тимур, хорошего настроения!