Это адаптация семантики, не исполнение OneScript и не полная совместимость всех внешних
плагинов upstream. Загружаются только явно указанные доверенные Python-модули через
--plugin package.module, с функцией register(host). Автопоиска/установки/запуска .os,
opm, bindata, чужих Git hooks нет. Python-модуль — произвольный код с правами процесса,
не sandbox: нельзя загружать недоверенные модули. Safety checks защищают операции ядра,
но не могут запретить самому Python-коду писать файлы или запускать процессы.
def register(host):
def change(ctx):
ctx.message = '[export] ' + ctx.message
ctx.author = 'Integration <integration@example.org>'
host.subscribe('before_commit', change, priority=100, contextual=True)subscribe(event, handler, priority=0, contextual=False). Приоритет по убыванию,
равные приоритеты — порядок регистрации. contextual=True: один изменяемый объект ctx
передаётся всем подписчикам события; изменения видны следующему подписчику и затем ядру
в перечисленных ниже изменяемых полях. Возвращаемое значение callback игнорируется.
Legacy contextual=False получает именованные аргументы прежних событий; mutable списки
работают как раньше. PluginHost(strict=True) по умолчанию: ошибки останавливают sync.
strict=False — явный старый режим предупреждений только для legacy callbacks;
ошибки contextual callbacks всегда fail closed.
Callbacks одного host сериализованы RLock. Export callbacks исполняются в worker threads,
но одновременно два callback одного host не исполняются. Сам backend остаётся параллельным.
Не ждать в callback другой callback/worker того же host: это может вызвать deadlock.
Порядок между различными версиями export не обещается; коммиты строго последовательные.
При retries export-события могут повторяться: требуются идемпотентность и отмена через cancel.
| Событие | Контекст | Эффект |
|---|---|---|
before_sync |
work_dir |
После recovery под writer lock, до проверки чистоты/чтения marker. Подходит для явно выбранной remote-политики. Изменение поля пути не перенаправляет ядро. |
before_history |
start, current_version, history=None, standard_processing=True |
Можно изменить start. False отменяет backend.fetch_history; необходимо предоставить history (список StorageVersion). |
after_history |
history, current_version |
Можно заменить список и записи через dataclasses.replace; сортировка и проверка дублей выполняются после callback. Текущий marker этим полем не изменяется. |
before_export |
version, destination, cancel, standard_processing=True |
False отменяет backend export; плагин обязан создать snapshot в выделенном destination. Переназначение destination/version не меняет выбранный ядром путь/номер. |
after_export |
version, destination |
Можно преобразовать подготовленный snapshot. Затем обязательны подтверждение выгрузки и reserved/symlink validation. |
before_cleanup |
version, work_dir, standard_processing=True |
False отменяет удаления отсутствующих в snapshot файлов (merge snapshot). Это изменение плана, не разрешение вручную чистить worktree. Перезапись пришедших файлов всё ещё выполняется транзакционно. |
before_commit |
version, work_dir, author, дополнительно message, date для contextual API |
author/message/date реально передаются в Git. VERSION/номер, путь, records, expected HEAD, journal и index lock не подменяются. Прямые изменения файлов отклоняются checks транзакции. |
after_commit |
version, work_dir, sha |
Только после durable commit. Ошибка → PostCommitError, ненулевой результат, commit и VERSION сохранены. |
after_sync |
work_dir, result |
Только успешное завершение, в том числе no-op. Ошибка возвращается, уже сделанные commits не откатываются. Не является finally/cleanup callback. |
История может запрашиваться повторно с start=1 для проверки усечения storage при no-op.
Очистка ядра выполняется в защищённом finally после остановки export pool, в том числе
при ошибке after_sync и при no-op. Удаляются только созданные этим run каталоги и
ресурсы backend через его cleanup(), не общий temp parent. Ошибка after_sync сохраняется
как исходный объект исключения; post_commit=True, если этот запуск подтвердил commits.
Ошибки отдельных cleanup действий не прерывают остальные: при уже имеющейся ошибке они
добавляются в __notes__ и журнал предупреждений, иначе становятся ошибкой результата.
--keep-temp (cleanup_temp=False) явно сохраняет и run exports, и backend resources,
включая ошибочные и no-op запуски: автоматический backend cleanup также отключён.
Init генерирует AUTHORS обычным backend.fetch_history и подчиняется ТОМУ ЖЕ lifecycle:
свой backend он отпускает в том же защищённом finally, в том числе при занятой цели.
Отличие одно — у init нет объекта результата, поэтому сбой самой очистки остаётся
предупреждением в журнале, а не ошибкой команды.
Каталог запуска нативного бэкенда (native-<uuid>) принадлежит запуску: бэкенд создаёт
его сам при ПЕРВОМ обращении к конфигуратору (owns_temp_root=True, exist_ok=False —
владение подтверждается созданием; первое создание из нескольких потоков сериализовано
замком) и в cleanup() снимает целиком. Прогон, упавший до
первого запуска конфигуратора, каталога не создаёт вовсе. Каталог, переданный
бэкенду снаружи, не удаляется никогда — общий родитель временных файлов, внешние и
постоянные данные вне области очистки, как и каталог, потерявший удостоверение.
Retention диагностики отделён от одноразового состояния: временные ИБ снимаются всегда,
а после ОТКАЗА каталог запуска остаётся с протоколами /Out, на путь которых ссылается
текст ошибки. Успешный и отменённый запуски не оставляют ничего.
Удаляется только то, что этот запуск создал сам, и принадлежность проверяется по УДОСТОВЕРЕНИЮ объекта файловой системы (устройство и inode), снятому в момент создания, а не по пути: путь к моменту очистки может вести куда угодно. Порядок существен — удостоверение каталога запуска сверяется ДО удаления чего-либо внутри него: рабочие каталоги лежат внутри, и подмена ПРЕДКА (символьная ссылка, соединение NTFS или просто другой настоящий каталог на том же пути) увела бы рекурсивное удаление в чужое дерево, хотя сам удаляемый каталог ссылкой при этом не является. Потеря удостоверения — отказ от очистки с предупреждением в журнале: оставленный каталог дешевле удалённых чужих данных.
Проверки мало — нужно ЗАКРЕПЛЕНИЕ. Проверка удостоверения непосредственно перед удалением по пути оставляет окно: между «проверили» и «удалили» путь можно подменить. Это окно закрыто тем, что удаление вообще не работает с путём — оно работает с объектом, открытым и удерживаемым на всё время операции.
| Кого защищаем | Чем подтверждено владение | Чем закреплён объект |
|---|---|---|
каталог запуска native-<uuid> |
создан нами (exist_ok=False) |
дескриптор/описатель каталога |
рабочий каталог worker-<uuid> |
создан нами | то же |
каталог выгрузки run-<uuid> |
создан нами | то же |
каталог версии v<N>-<hex> |
лежит ВНУТРИ нашего run-<uuid> |
закреплён РОДИТЕЛЬ, удаление идёт относительно него |
Каталог версии создаёт бэкенд или плагин (before_export вправе взять выгрузку на себя),
поэтому своего удостоверения у него нет и быть не может; он снимается как запись внутри
каталога выгрузки, удостоверение которого снято при создании.
Что защита даёт и чего не даёт:
- POSIX. Каталог открывается с
O_DIRECTORY | O_NOFOLLOW, удостоверение сверяется у ОТКРЫТОГО каталога (fstat), и содержимое снимается относительно дескриптора (dir_fd): дескриптор держит сам объект, поэтому подмена пути после проверки удаление наружу не уводит. Сам каталог снимается через дескриптор РОДИТЕЛЯ, причём запись с нашим именем сверяется по удостоверению непосредственно перед снятием. - Windows. Роль дескриптора играет описатель каталога, открытый без
FILE_SHARE_DELETE. Пока он открыт, система запрещает переименовать и удалить как сам каталог, так и любого его ПРЕДКА (измерено из отдельного процесса: сам каталог —ERROR_SHARING_VIOLATION, предки —ERROR_ACCESS_DENIED), то есть подмена в этот момент невозможна не «потому что мы успели проверить», а потому что её отвергает ОС. Сам каталог снимается по описателю (FileDispositionInfoEx), и путь в удалении не участвует вовсе. - Остаточное окно есть — и только одно. В POSIX нет удаления каталога по дескриптору:
unlinkatпринимает имя, поэтому между сверкой записи и её снятием остаются микросекунды. Воспользоваться ими можно, лишь имея право удалить НАШУ запись из общего родителя (на каталогах со «липким» битом — от того же пользователя), и снять при этом удастся только ПУСТОЙ каталог:rmdirне трогает ни содержимое, ни ссылки. На Windows этого окна нет. Утверждение «подмена невозможна» относится к Windows; для POSIX утверждается ровно написанное выше. - Точка повторного разбора (символьная ссылка, соединение NTFS) внутри своего каталога снимается КАК ССЫЛКА: её цель не затрагивается ни на одной платформе.
- Проверка не защищает от того, кто может писать ВНУТРЬ каталога запуска: этот каталог одноразовый и по построению принадлежит запуску целиком. Защищается граница «не выйти наружу», а не содержимое своего каталога. Подброшенный туда чужой каталог будет снят вместе с нашим — тот, кто пишет в наш одноразовый каталог, уже внутри доверия прогона.
- Том, не отдающий inode (
st_ino == 0), удостоверения не даёт: на таком томе очистка отказывается, а не удаляет наугад. - Это не блокировка: параллельное удаление того же каталога кем-то ещё приводит к штатному отказу очистки, а не к ошибке команды.
cleanup()освобождает ресурсы ВСЕГО экземпляра и вызывается после остановки пула. Одновременная работа другого потока в том же каталоге запуска — не поддерживаемый порядок; замок внутри гарантирует лишь, что учёт не разойдётся с диском (каталог не окажется снятым, но числящимся, или созданным, но не учтённым).
Предыдущий пункт — «тот, кто пишет в наш одноразовый каталог, уже внутри доверия прогона» — верен, но он не ответ на вопрос, КТО этот «тот». Ответ определяется не кодом, а тем, ГДЕ лежит каталог запуска, и это замерено на стенде двумя штатными контейнерами с одним и тем же UID 10001:
| где лежит каталог запуска | видит ли его СОСЕДНИЙ контейнер | что он с ним сделал |
|---|---|---|
| bind-mount каталога хоста | да, и читает выгрузку | rm -rf прошёл, каталог уничтожен |
| том Docker, именованный | да, и читает выгрузку | то же |
том Docker, анонимный (--volumes-from) |
да, и читает выгрузку | то же |
| собственный слой контейнера | нет, не видит вовсе | выгрузка цела |
Отсюда договор контейнерной поставки: каталог запуска — /var/lib/gitsync/scratch, он
часть образа и НЕ точка монтирования. Нарушение отвергается дважды: пусковым сценарием
(examples/gitsync-run.sh, код 78) и самим заданием (UnsafePathError) — и во втором
случае отказ происходит ДО создания каталога и ДО любого удаления.
Что этим не закрыто и закрытым не объявляется: враждебный процесс под тем же UID
ВНУТРИ того же контейнера. В POSIX удаление адресует ИМЯ (rmdir/unlinkat
привязки к inode не имеют), правами того же владельца сосед не выгораживается, а
flock для него необязателен — измерения stage-17. Частный каталог запуска убирает
соседний КОНТЕЙНЕР, а не соседний процесс внутри своего. Поэтому код внутри контейнера —
включая плагины — обязан быть доверенным, и это условие поставки, а не следствие проверок.
Диагностика после отказа — это файл /Out в native-<uuid>/designer-out/: он остаётся,
и на него ссылается текст ошибки. Остальные пути, которые сообщение повторяет за
командной строкой конфигуратора (worker-*/ib, storage-report-*.mxl), одноразовые: к
моменту чтения сообщения их уже нет, и это ожидаемое поведение, а не потеря диагностики.
Пустой/missing override snapshot подчиняется тому же allows_empty_export контракту backend.
Reserved-path validation повторяется перед построением транзакции; baseline проверяется
после cleanup callback. Флаг standard_processing не отключает safety/recovery gates.
МенеджерПодписок.os:775–799 — убывающие приоритеты; 802–840 — возврат mutable параметров.
МенеджерСинхронизации.os:264–357 — lifecycle, 382–425 — export,
566–588 — стандартная очистка/override. Перенесены ключевые data-oriented stages;
не перенесены все 30+ событий, native-конфигуратор/IB contexts, произвольная замена Git commit,
низкоуровневых load/move, CLI subscription и установка/включение пакетов upstream.
Полная drop-in plugin parity не заявляется.
Без явно загруженного модуля sync не выполняет fetch/pull/push. clone --url — отдельная
явная операция. Источник upstream sync-remote — внешний проект, его точный контракт в
исследованном snapshot отсутствует; совместимость с ним не заявляется.
tests/test_parity07_remote.py исполняет настоящий Python-модуль с before_sync:
clean → fetch → merge --ff-only и after_commit: push. Только собственный local bare fixture,
без сети. Проверены remote advance, successful push, divergence refusal и pre-receive rejection.
При отклонённом push локальный commit остаётся; в тесте оператор явно повторяет push после
устранения отказа. Универсального установленного remote-плагина, автоматического retry push
на no-op, rebase, force push, произвольной branch/auth политики в продукте нет.