Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ff149c35 | ||
|
|
eff704c675 | ||
|
|
6e9c163047 | ||
|
|
871bde1abd | ||
|
|
5c26bbe8b5 | ||
|
|
3d7df25d00 | ||
|
|
4d589eee95 | ||
|
|
3c17e2eb3b | ||
|
|
54e30d4228 | ||
|
|
00b0af69c2 | ||
|
|
81ab7aac39 | ||
|
|
8748760594 | ||
|
|
9d553744a1 | ||
|
|
710c483f34 | ||
|
|
f19faf628e |
No files matched your search
@@ -2,6 +2,24 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Все продуктовые требования Акылдаша собраны в одном документе
|
||||
[«Функциональность платформы»](docs/product/platform-functionality.md),
|
||||
включая границы публичного MVP, критерии выпуска и дальнейшее развитие.
|
||||
Заменённые планы и исходное Word-ТЗ доступны в истории Git.
|
||||
- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea
|
||||
commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer
|
||||
проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A
|
||||
может подготовить release B только при точном совпадении pinned runtime-файлов
|
||||
и release policy. Первый Windows task остаётся preparation-only; отдельные
|
||||
auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для
|
||||
bundle-проверки завершился успешно на точном PR SHA; реальная установка,
|
||||
регистрация задач, SSH и production не выполнялись и не активировались.
|
||||
- Infrastructure: Windows release installer и external receiver читают владельцев
|
||||
и ACL ACE напрямую как SID, без преобразования в имена учётных записей. Это
|
||||
устраняет зависимость проверки от разрешения имён; неизвестные SID остаются
|
||||
видимыми и отклоняются существующими fail-closed guard-политиками. Проверены
|
||||
synthetic unmapped-SID fixtures и read-only ACL preflight; production ACL не
|
||||
менялись.
|
||||
- Документация: объединены продуктовый, UI- и deployment-планы; уточнены
|
||||
внешняя backup-retention политика и обязательный owner-approved maintenance
|
||||
window для cold backup. Передача корпуса требует атомарной публикации
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# Акылдаш
|
||||
|
||||
Акылдаш — проект юридической информационно-аналитической платформы. Цель —
|
||||
собирать правовые источники на законных основаниях, сохранять их происхождение
|
||||
и версии, готовить данные для поиска и RAG, а затем предоставлять результаты
|
||||
через API и пользовательский интерфейс со ссылками на первоисточники.
|
||||
Акылдаш — проект юридической информационно-аналитической платформы
|
||||
Кыргызской Республики. Целевые возможности и требования к выпуску ведутся в
|
||||
[функциональности платформы](docs/product/platform-functionality.md).
|
||||
|
||||
Telegram-бот — только часть рабочего окружения команды, а не основной продукт.
|
||||
|
||||
@@ -51,11 +50,7 @@ backend/
|
||||
```
|
||||
|
||||
Внутренний frontend отдаётся существующим Search API без отдельного
|
||||
Node-сервиса. Отдельный публичный frontend остаётся за readiness gate в
|
||||
`docs/product/frontend-design-readiness-plan.md`.
|
||||
|
||||
Начать знакомство с проектом: [документация](docs/README.md) и
|
||||
[обзор продукта](docs/product/project-overview.md).
|
||||
Node-сервиса. Остальные материалы собраны в [оглавлении документации](docs/README.md).
|
||||
|
||||
## Конфиденциальные материалы
|
||||
|
||||
|
||||
+4
-11
@@ -4,17 +4,10 @@
|
||||
|
||||
- [Дизайн-система «Түндүк»](../DESIGN.md) — утверждённые палитра,
|
||||
типографика, компоненты и правила адаптивности портала и лаборатории.
|
||||
- [Обзор проекта](product/project-overview.md) — назначение, основные области и
|
||||
правила работы с данными.
|
||||
- [Стратегическое сравнение с Токтом](product/platform-development-plan-toktom-2026-10-24.md) —
|
||||
продуктовая гипотеза и конкурентный анализ; это не план исполнения задач.
|
||||
- [План frontend поисковой СПС](product/frontend-search-sps-plan.md) — границы
|
||||
MVP, зависимости и спринты.
|
||||
- [Готовность к проектированию frontend](product/frontend-design-readiness-plan.md) —
|
||||
обязательные работы и критерии перехода к frontend.
|
||||
- [План реализации и приёмки](product/implementation-roadmap.md) — единая
|
||||
очередность UI и deployment gates, со статусом доказательств; не дублирует
|
||||
продуктовую стратегию или эксплуатационные процедуры.
|
||||
- [Функциональность платформы](product/platform-functionality.md) — единая
|
||||
спецификация возможностей, границы публичного MVP и условия выпуска.
|
||||
- [План реализации и приёмки](product/implementation-roadmap.md) — реестр
|
||||
UI-аудита, технические критерии приёмки и порядок подготовки deployment.
|
||||
- [План интерфейса оценки поисковой выдачи](product/search-relevance-review-interface-plan.md) —
|
||||
внутренняя лаборатория сбора оценок юристов для настройки OpenSearch.
|
||||
- [Разметка relevance set](../backend/search/RELEVANCE_ANNOTATION.md) — подготовка
|
||||
|
||||
@@ -1,28 +1,70 @@
|
||||
# Infrastructure current state
|
||||
|
||||
## Proposed external deployment runtime binding — 2026-11-01
|
||||
## Windows ACL SID resolution — 2026-11-03
|
||||
|
||||
- PR #59 now proposes a root-only read-only status endpoint and exact-literal
|
||||
SSH dispatcher for `status` and `deploy`. The status path bounds the Gitea
|
||||
run list to ten entries, verifies the selected run/workflow/current `main`,
|
||||
- Read-only PowerShell 5.1 inspection found that display-name conversion of
|
||||
ACL entries can throw `IdentityNotMappedException` even though the ACL itself
|
||||
is readable. The installer and external-receiver guards now enumerate access
|
||||
rules directly as `SecurityIdentifier` values and read owners as SIDs; unknown
|
||||
principals remain visible to the existing fail-closed policy. Regression
|
||||
coverage exercises synthetic unmapped SIDs through the actual installer guard
|
||||
functions. These repository changes are pending review; no Windows ACL,
|
||||
installer, task, VPS, or production state was changed. The native PowerShell
|
||||
harness still requires execution on Windows before this behavior is confirmed
|
||||
in that runtime.
|
||||
|
||||
## External deployment runtime binding — 2026-11-03
|
||||
|
||||
- PR #59 is merged to `main`; it contains the root-only read-only status
|
||||
endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The
|
||||
status path bounds the Gitea run list to ten entries, verifies the selected
|
||||
run/workflow/current `main`,
|
||||
validates artifact metadata and ZIP bytes, and returns only a release tuple.
|
||||
It fails closed for a stale host checkout, recovery journal, incomplete
|
||||
listing, or the exact release already recorded as applied. The deploy
|
||||
receiver still independently revalidates and uses the existing lock,
|
||||
recovery journal, cold-backup ACK, cutover, and rollback protections.
|
||||
- The Windows client proposes a disabled-by-default `--once` mode with a
|
||||
fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example
|
||||
config and Task Scheduler XML are both disabled; the template uses an
|
||||
interactive least-privilege owner, ignores overlapping invocations, and
|
||||
does not wake the computer. No Windows task, SSH key, VPS command, helper,
|
||||
or trigger config has been installed or exercised. Production CD remains
|
||||
inactive.
|
||||
- Remaining owner prerequisites are exact root forced-command/SSH host-key
|
||||
setup for the dispatcher, installation of the reviewed Windows client under
|
||||
a non-user-writable path, private ACLs for its config and D: backups, a
|
||||
root-controlled checkout matching current `main`, and all previously listed
|
||||
backup/restore and production readiness gates. The status endpoint does not
|
||||
advance the host checkout and refuses to offer a stale release.
|
||||
This unmerged change adds a two-SHA compatibility gate: the fixed root helper
|
||||
compares protected Compose/Caddy bytes and installed release policy with
|
||||
Gitea content pinned to the release SHA, verifying each Git blob hash and
|
||||
size. This permits application-only release B after bootstrap A without
|
||||
advancing the root checkout or executing new root code. A runtime config or
|
||||
policy change fails closed and requires a reviewed bootstrap update. The
|
||||
Windows response state is `prepared`, never `candidate`; the `--once` task
|
||||
stores a protected local tuple and cannot call deploy. A separate disabled
|
||||
auto-apply task and config gate require the tuple to match a fresh status
|
||||
response from a previous poll. Status and apply retain current-main,
|
||||
successful CI/release provenance, descriptor digest, external backup,
|
||||
cutover, canary, and rollback checks. The receiver repeats the config check
|
||||
before journaling/stopping services and again after backup immediately before
|
||||
switching refs.
|
||||
- Windows client предназначен для подготовки и auto-apply; обе задачи по умолчанию
|
||||
выключены, целевой путь фиксирован: `D:\Dev\akyldash`, SSH alias —
|
||||
`akyldash_release`. Примеры config и Task Scheduler XML также выключены; задачи
|
||||
запускаются от интерактивного пользователя с минимальными правами, не допускают
|
||||
параллельных запусков и не будят компьютер. Windows task, SSH key, VPS command,
|
||||
helper и trigger не устанавливались и не запускались. Путь canonical
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` известен, но содержимое и
|
||||
enabled-состояние не читались. Метаданные Windows подтверждают
|
||||
`C:\Python313\python.exe`, действительную Authenticode-подпись Python Software
|
||||
Foundation и защищённые ACL интерпретатора.
|
||||
- Linux fixtures прошли для Python import и command-path poisoning, включая очистку
|
||||
унаследованных `GIT_*` и фиксацию путей `ssh.exe`/`git.exe`/`powershell.exe`.
|
||||
Installer получает ограниченный bundle из точной вершины `main`: упаковщик
|
||||
читает Git objects commit, tree и allowlist-файлов; elevated installer проверяет
|
||||
SHA-256 ZIP и цепочку `commit → tree → файлы` из одного read-locked потока до
|
||||
записи и не запускает Git. ACL проверяются явными масками мутаций и подмены;
|
||||
текущая учётная запись не может владеть кодовыми путями, а право создать
|
||||
соседнюю папку не считается подменой предка. Windows ACL/install проверки пока
|
||||
только прошли код-ревью; installer и launcher на Windows не запускались.
|
||||
Production CD остаётся выключенным.
|
||||
- Remaining owner prerequisites are installation of the reviewed root helper,
|
||||
exact forced-command/SSH host-key setup, a protected Windows client and its
|
||||
private state/config ACLs, private D: backup ACLs, and all previously listed
|
||||
backup/restore and production readiness gates. Neither the status endpoint nor client advances
|
||||
the host checkout or updates root helper code.
|
||||
|
||||
- Read-only Gitea check for fresh `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release
|
||||
run #708 failed at the explicit registry credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was
|
||||
skipped. No secret values were read. Image publication remains owner-blocked.
|
||||
|
||||
Owner decision recorded 2026-10-26: a complete backup must not be stored on
|
||||
the VPS, including temporarily. The local archive/full cold-backup commands in
|
||||
|
||||
+113
-45
@@ -2,10 +2,12 @@
|
||||
|
||||
## Status and authorization
|
||||
|
||||
The implementation in PR #59 is not merged, installed, or exercised against
|
||||
the VPS. No release, backup, restore, or production service operation has been
|
||||
performed by this change. Production deployment remains blocked until the
|
||||
owner has verified host state, registry access, protected configuration,
|
||||
The release and fixed-receiver code from PR #59 is on `main`, but has not been
|
||||
installed or exercised against the VPS. This PR's compatibility and Windows
|
||||
bootstrap changes are still proposed. No release, backup, restore, or
|
||||
production service operation has been performed by this change. Production
|
||||
deployment remains blocked until the owner has verified host state, registry
|
||||
access, protected configuration,
|
||||
corpus/index readiness, RU/KY canaries, an external backup, and restore on a
|
||||
separate target. See [current state](CURRENT_STATE.md) and the
|
||||
[owner evidence packet](DEPLOYMENT_OWNER_PACKET.md).
|
||||
@@ -26,6 +28,13 @@ The descriptor records both the release workflow run ID and the successful CI
|
||||
source run ID. PR builds do not publish images. Production runtime secrets are
|
||||
not supplied to CI.
|
||||
|
||||
Read-only Gitea check on 2026-11-03 for current `main` SHA
|
||||
`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release
|
||||
run #708 failed at the explicit registry-credential-presence check because
|
||||
`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run
|
||||
#709 was skipped. No secret values were read. Image publication therefore
|
||||
remains blocked until the owner provisions the required registry secrets.
|
||||
|
||||
On the host, the fixed root receiver uses the installed `gitea-api` helper to
|
||||
re-fetch and verify the exact successful CI and release runs, current `main`,
|
||||
artifact name, checksum, manifest schema, builder pin, image policy, and commit
|
||||
@@ -35,46 +44,103 @@ the response, and refuses to overwrite an existing file. The installer checks
|
||||
the exact audited helper fixture hash before applying its patch. The helper
|
||||
and source code are not installed on the VPS by this PR.
|
||||
|
||||
## One-shot runtime binding proposed by PR #59
|
||||
## One-shot runtime binding and bootstrap compatibility
|
||||
|
||||
PR #59 adds a root-only, read-only status command and a fixed SSH dispatcher.
|
||||
`main` includes a root-only, read-only status command and a fixed SSH dispatcher.
|
||||
The dispatcher accepts only the literal status or deploy command in
|
||||
`SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell
|
||||
evaluation. Status fails closed on a stale root checkout, recovery journal,
|
||||
incomplete Gitea run listing, failed source/workflow checks, invalid artifact
|
||||
metadata/ZIP, or a release already applied. The candidate response contains
|
||||
only a run ID, commit, and descriptor digest. The root receiver independently
|
||||
repeats release verification and the existing backup/ACK/cutover guards.
|
||||
evaluation. Status verifies successful current-main provenance, artifact
|
||||
metadata/ZIP, and host runtime compatibility before returning only a run ID,
|
||||
commit, and descriptor digest in a `prepared` response. It fails closed on a
|
||||
recovery journal, incomplete Gitea run listing, invalid release data, changed
|
||||
host runtime configuration, or an already applied release. The root receiver
|
||||
independently repeats release verification and the existing backup/ACK/cutover
|
||||
guards.
|
||||
|
||||
The Windows client adds a one-shot `receive_external.py --once` mode. Its
|
||||
configuration is fixed to `D:\Dev\akyldash` and the `akyldash_release` SSH
|
||||
alias, and is disabled by default. It uses BatchMode, disables forwarding,
|
||||
uses the existing host-key checking, bounds status output and time, then calls
|
||||
the existing `run_release` path. Rollback remains a separate explicit
|
||||
owner-run command. The example Task Scheduler XML is disabled, runs as the
|
||||
interactive owner at least privilege, ignores overlapping instances, and
|
||||
never wakes the computer. No task has been registered or enabled by this PR.
|
||||
The Windows `--once` mode only polls the fixed read-only status endpoint and
|
||||
stores a protected local `PREPARED` tuple. It never sends a deploy request.
|
||||
Automatic deployment uses a second task and a separate
|
||||
`release-auto-apply.json` gate; both config files and both scheduler XML
|
||||
templates default to disabled. The apply task acts only when its current
|
||||
verified tuple exactly matches a tuple saved by a previous poll. An owner can
|
||||
also use `--apply-prepared`, which requires an interactive exact-SHA
|
||||
confirmation. Both deploy paths re-enter the existing root receiver and all
|
||||
backup/ACK/cutover guards. Rollback remains a separate explicit owner-run
|
||||
командой. Планировщик запускает задачи от интерактивного владельца с минимальными
|
||||
правами, пропускает повторный запуск при активном экземпляре и не будит компьютер.
|
||||
Этот PR не зарегистрировал и не включил ни одну задачу.
|
||||
|
||||
Before any activation, the owner must install the reviewed receiver and its
|
||||
`archive.py` and `external_session.py` dependencies under a non-user-writable
|
||||
`C:\Program Files\Akyldash` directory, set the config ACL so
|
||||
only the owner, SYSTEM, and Administrators have full control, fill in the real
|
||||
Python executable, owner SID, and local start time in the task template, and
|
||||
confirm SSH host-key checking and the forced-command alias. Keep `enabled`
|
||||
false and the task
|
||||
disabled until the normal backup/restore and production readiness gates are
|
||||
recorded. Do not bind a Gitea workflow or PR runner to this Windows host.
|
||||
Сначала подготовьте bundle под обычной учётной записью из точной локальной
|
||||
вершины `main` командой `prepare_windows_release_bundle.py`. Упаковщик читает
|
||||
только Git objects указанного полного commit SHA, сверяет Git blob identity
|
||||
исходников и создаёт небольшой ZIP с SHA-256; рабочее дерево он не читает и не
|
||||
копирует. Elevated installer получает путь к ZIP, ожидаемый commit и SHA-256.
|
||||
Он один раз открывает пакет с запретом записи/удаления, проверяет SHA-256 архива,
|
||||
manifest и Git blob IDs в памяти, затем копирует только проверенные байты.
|
||||
Дополнительно installer проверяет цепочку Git objects `commit → tree → файлы`.
|
||||
На elevated этапе Git не запускается; `.git/config`, hooks и состояние checkout
|
||||
не используются. Не изменяйте ZIP между подготовкой и установкой.
|
||||
Упаковщик ставит всем ZIP entries тип regular file и mode `0100600`; installer
|
||||
проверяет тот же mode (`0x8180`) до чтения содержимого. Для проверки стыка
|
||||
упаковщика и фактической функции mode-валидации на Windows используйте
|
||||
`powershell.exe -NoProfile -File infra/deploy/test_windows_release_bundle_mode.ps1 -PythonPath C:\Python313\python.exe`;
|
||||
`git.exe` должен уже разрешаться обычным `PATH`. Harness создаёт синтетический
|
||||
Git репозиторий и временный ZIP, извлекает только эту функцию из PowerShell AST и
|
||||
проверяет принятие штатного пакета и отказ после изменения mode. Он не запускает
|
||||
installer, не требует elevation, не меняет ACL и не регистрирует задачи; политику
|
||||
выполнения PowerShell менять для него не следует.
|
||||
|
||||
The root-side status endpoint requires `/opt/akyldash/current` to exactly
|
||||
match current `main`; it does not update that checkout. An owner must advance
|
||||
the root-controlled checkout through the approved installation process before
|
||||
a release built from a newer commit can be considered ready. Gitea's filtered
|
||||
workflow-run listing is bounded to ten entries; if Gitea reports more entries
|
||||
or an inconsistent page, status returns unavailable rather than guessing.
|
||||
Проверенный bootstrap installer проверяет подпись PSF у
|
||||
`C:\Python313\python.exe` и защищённые ACL, затем размещает файлы клиента в
|
||||
SHA-версионном каталоге `Program Files`, не перезаписывая существующую версию.
|
||||
Для новых файлов кода владельцем назначается `Administrators`. У кода владельцем
|
||||
могут быть только SYSTEM, Administrators или TrustedInstaller; учётная запись
|
||||
задачи допускается владельцем только для отдельного private state/config.
|
||||
Проверка ACL файла и защищённого каталога запрещает недоверенным субъектам запись,
|
||||
добавление, удаление, удаление дочерних объектов, смену прав или владельца. Для
|
||||
родительских каталогов проверяются права, способные подменить защищённый путь;
|
||||
разрешение создать посторонний соседний каталог подменой не считается.
|
||||
Installer сохраняет существующий canonical-файл
|
||||
`C:\ProgramData\Akyldash\release-trigger.json`, только если он выключен; второй
|
||||
disabled auto-apply config создаётся только при отсутствии. Installer формирует
|
||||
два disabled task XML для проверки владельцем, но не регистрирует и не включает
|
||||
задачи. Launcher запускает Python с `-B -E -s -S`, оставляет в import path только
|
||||
стандартную библиотеку и защищённый каталог клиента, затем проверяет происхождение
|
||||
импортированных модулей. До импорта receiver он заменяет унаследованный command
|
||||
lookup фиксированными каталогами Windows, OpenSSH и Git, очищает `GIT_*` и
|
||||
проверяет, что `ssh.exe`, `git.exe` и `powershell.exe` разрешаются в заданные пути.
|
||||
Для самих бинарников и их родительских каталогов проверяются reparse points,
|
||||
владельцы и ACL по правилам выше. Тесты подмены Python imports и command paths
|
||||
выполнены на Linux; installer и launcher на Windows не запускались. Оставляйте оба
|
||||
config gate и обе задачи выключенными до фиксации владельцем обычных backup/restore
|
||||
и production readiness gate. Не привязывайте Windows-хост к Gitea workflow или PR runner.
|
||||
|
||||
The root status/deploy helper remains the reviewed root-installed bootstrap
|
||||
code; it never checks out or executes target release code. A newer app release
|
||||
is eligible without manually advancing `/opt/akyldash/current` only when the
|
||||
host's Compose file, Caddyfile, and installed release policy match the exact
|
||||
bytes at the prepared commit in Gitea. File metadata is pinned to that commit;
|
||||
the Git blob SHA and size are verified. Status checks before preparing the
|
||||
tuple; the receiver repeats the check before creating a journal or stopping
|
||||
services, and cutover checks again after the verified external backup and
|
||||
immediately before switching image references. A Compose/Caddy mismatch
|
||||
returns `runtime_config_changed`; a policy mismatch returns
|
||||
`release_policy_changed` and requires a reviewed owner bootstrap update. No
|
||||
host checkout or root helper is automatically changed. The release must still
|
||||
be current `main`; CI/release provenance, digest, backup, canary, and rollback
|
||||
checks remain required. Gitea's filtered workflow-run listing is bounded to
|
||||
ten entries; if Gitea reports more entries or an inconsistent page, status
|
||||
returns unavailable rather than guessing.
|
||||
|
||||
## Automation boundary: deployment is not active
|
||||
|
||||
The automatic chain currently ends at release publication:
|
||||
Until the owner separately activates both Windows tasks and the auto-apply
|
||||
configuration after the backup/restore and production gates, the chain ends at
|
||||
release publication. After that explicit one-time owner authorization, the
|
||||
prepared-state poll and separate apply task can process later matching
|
||||
application releases without advancing `/opt/akyldash/current` or approving
|
||||
each commit individually. A release that changes protected runtime files still
|
||||
requires a reviewed bootstrap update.
|
||||
|
||||
1. Successful `main` CI triggers image builds and publishes digest references
|
||||
plus the release descriptor artifact.
|
||||
@@ -82,14 +148,15 @@ The automatic chain currently ends at release publication:
|
||||
Its job is explicitly status-only: **NOT DEPLOYED**. It does not download
|
||||
the descriptor ZIP, call the Windows client, open SSH, or invoke the root
|
||||
receiver. A green result means only that this status check completed.
|
||||
3. The fixed receiver is physically started only by an explicitly enabled
|
||||
owner-controlled Windows trigger or by the manual release CLI. The proposed
|
||||
`--once` path queries root's verified status endpoint and receives the
|
||||
fixed tuple; no repository workflow invokes it. The example scheduler and
|
||||
config remain disabled, and no Windows trigger has been installed or
|
||||
exercised.
|
||||
3. The fixed receiver is physically started only by the separately enabled
|
||||
owner-controlled auto-apply task or by an interactive owner command. The
|
||||
`--once` preparation task stores a verified tuple and never sends deploy;
|
||||
the separate apply task requires the tuple to match a fresh status response.
|
||||
No repository workflow invokes either task. Both scheduler templates and
|
||||
both configuration gates remain disabled, and neither task has been
|
||||
installed or exercised.
|
||||
|
||||
Production CD is still not active: no Windows task or credential is installed,
|
||||
Production CD is still not active: task/credential setup has not been verified,
|
||||
and no VPS or live D: operation has been performed. This design avoids a
|
||||
Windows Actions runner entirely; the owner-controlled local task is not exposed
|
||||
to PR jobs. The SSH account/key must be restricted to the exact dispatcher
|
||||
@@ -150,9 +217,10 @@ from a root-owned checkout and provision, without exposing values in logs:
|
||||
exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY,
|
||||
or arbitrary command access;
|
||||
- a protected installation of the Windows client under
|
||||
`C:\Program Files\Akyldash`, with the sample JSON and Task Scheduler XML
|
||||
reviewed and kept disabled until the owner explicitly accepts unattended
|
||||
polling.
|
||||
`C:\Program Files\Akyldash`, with both config files and both Task Scheduler
|
||||
XML templates reviewed. Keep the preparation task/config separate from the
|
||||
auto-apply task/config; the auto-apply pair remains disabled until the owner
|
||||
explicitly accepts unattended production deployment.
|
||||
|
||||
The installer intentionally does not create credentials, SSH entries, canary
|
||||
values, registry permissions, or a new privilege rule. Existing files with
|
||||
|
||||
@@ -1,91 +1,125 @@
|
||||
# Deployment owner evidence packet
|
||||
# Пакет проверок владельца перед деплоем
|
||||
|
||||
This packet is a list of owner-only checks. It does not authorize a production
|
||||
change. Do not include token/password values, environment-file contents,
|
||||
private keys, secret material, or raw process environments.
|
||||
Здесь перечислены проверки, которые выполняет владелец. Этот документ не
|
||||
разрешает production-изменения. Не включайте значения токенов и паролей,
|
||||
содержимое env-файлов, приватные ключи, другие секреты или полный вывод
|
||||
переменных окружения.
|
||||
|
||||
## Required before release use
|
||||
## Что требуется до использования CD
|
||||
|
||||
- PR #59 proposes a disabled one-shot Windows trigger. No task or endpoint is
|
||||
installed. If the owner chooses to activate it after merge and all other
|
||||
gates, install the reviewed `receive_external.py`, `archive.py`, and
|
||||
`external_session.py` client files in `C:\Program Files\Akyldash` with
|
||||
write access limited to administrators/SYSTEM, copy
|
||||
`infra/deploy/windows-release-trigger.example.json` to
|
||||
`C:\ProgramData\Akyldash\release-trigger.json` and retain
|
||||
`enabled:false`, then grant only the interactive owner, SYSTEM, and
|
||||
Administrators full control on the config and its parent. Fill in the real
|
||||
Python path, owner SID, and a future local start time in the XML template.
|
||||
Review/import the task while
|
||||
disabled; it runs as that logged-on owner at least privilege, ignores a
|
||||
concurrent instance, does not wake the PC, and invokes only `--once`. Enable
|
||||
the JSON and task only after the owner separately approves unattended
|
||||
polling. Disable either one to stop future polls; an already running cold
|
||||
backup/deploy must be allowed to finish or be explicitly recovered.
|
||||
- The `akyldash_release` SSH key must be dedicated to the root forced-command
|
||||
dispatcher, with OpenSSH `restrict` (or equivalent no-PTY/no-forwarding
|
||||
options), verified host-key checking, and exact status/deploy command
|
||||
matching. Do not put an unrestricted command or shell in root's
|
||||
`authorized_keys`. The existing rollback key/path remains a separate manual
|
||||
owner action. No key is generated, copied, or installed by this PR.
|
||||
- Decide whether unattended production CD is acceptable. It currently is not
|
||||
active: `deploy-handoff.yaml` remains status-only and reports `NOT DEPLOYED`;
|
||||
the proposed local task and its config remain disabled. This avoids exposing
|
||||
the Windows host to repository PR workflows. Verify that the root status
|
||||
command is installed from a reviewed checkout, its forced-command key is
|
||||
restricted, and the host checkout exactly matches current `main`. It does
|
||||
not update that checkout automatically. The endpoint fails closed if the
|
||||
filtered workflow-run API page is incomplete (more than ten candidates or
|
||||
count mismatch), so status API behavior must be checked on the live Gitea
|
||||
before activation.
|
||||
- Confirm the registry package names and least-privilege writer/pull access for
|
||||
`admin/akyldash/backend` and `admin/akyldash/opensearch`; record digest
|
||||
retention for the rollback window.
|
||||
- Confirm Gitea artifact retention covers the rollback window. The workflow
|
||||
requests 90 days for the small release descriptor and checksum.
|
||||
- Provide a dated, sanitized root audit: installed checkout SHA, Compose
|
||||
services and image IDs, health state, volume paths/modes, protected-file
|
||||
owner/mode, listener summary, and current recovery-journal state.
|
||||
- Confirm a root-controlled SSH path exists for the fixed deploy and rollback
|
||||
commands, using existing owner SSH configuration and verified host-key
|
||||
checking. The repository `codex` account must not receive general sudo,
|
||||
Docker-group membership, or a Docker socket. No SSH key or root alias is
|
||||
provisioned by this PR.
|
||||
- If required, install a narrowly scoped, read-only Gitea API token at
|
||||
`/etc/akyldash/gitea-api-token` with root ownership and mode `0600`. Record
|
||||
only whether it is present and its scope; never send its value.
|
||||
- Verify the Gitea artifact download origin. If redirected, provision only the
|
||||
exact HTTPS origin in `/etc/akyldash/gitea-download-origins` as a root-owned
|
||||
mode-`0600` file. The patched helper must not send its token to that origin.
|
||||
- Provide owner-approved RU and KY search canary queries in
|
||||
`/etc/akyldash/release-canaries.json`, root-owned mode `0600`. Confirm they
|
||||
return expected non-empty results without sending corpus documents.
|
||||
- Confirm Cloudflare TLS mode, origin certificate renewal, and current public
|
||||
`/review` and search health.
|
||||
- Отключённый одноразовый Windows trigger из PR #59 уже находится в `main`, но
|
||||
не установлен и не запускался. Bootstrap из этого PR также не установлен.
|
||||
После ревью и слияния обновите локальный `main` до проверенного полного SHA.
|
||||
Под обычным пользователем подготовьте bundle командой
|
||||
`prepare_windows_release_bundle.py --repository D:\Dev\akyldash --commit <SHA> --output <new ZIP path>`.
|
||||
Упаковщик требует, чтобы SHA совпадал с локальной вершиной `main`; он читает
|
||||
Git objects этого commit, а не рабочее дерево, проверяет SHA-1 объектов
|
||||
commit/tree/blob и печатает SHA-256 ZIP. Передайте именно эти SHA и путь
|
||||
elevated installer:
|
||||
`install_windows_release_client.ps1 -ExpectedCommit <SHA> -BundlePath <path> -ExpectedBundleSha256 <digest>`.
|
||||
Installer открывает ZIP один раз с запретом записи и удаления, сверяет SHA-256,
|
||||
manifest и цепочку `commit → tree → файлы` в памяти и только после полной
|
||||
проверки копирует эти байты. Elevated installer не запускает Git и не читает
|
||||
`.git/config`, hooks или файлы checkout. Не меняйте ZIP между подготовкой и
|
||||
установкой; сохраните SHA и полный commit для журнала владельца.
|
||||
- Installer проверяет подпись PSF у `C:\Python313\python.exe` и защищённые
|
||||
ACL, создаёт версионный каталог в `Program Files` без перезаписи существующей
|
||||
версии и назначает владельцем новых файлов кода `Administrators`. Кодовые
|
||||
пути должны принадлежать только SYSTEM, Administrators или TrustedInstaller;
|
||||
учётная запись задачи допускается владельцем только для отдельных private
|
||||
state/config. ACL-проверка файла/кодового каталога выявляет запись, добавление,
|
||||
удаление, удаление дочерних объектов, смену ACL и владельца. Проверка родительских
|
||||
каталогов выявляет права, позволяющие заменить защищённый путь; создание
|
||||
постороннего соседнего каталога само по себе не считается подменой.
|
||||
- Installer сохраняет существующий `C:\ProgramData\Akyldash\release-trigger.json`
|
||||
только если он остаётся отключённым, создаёт disabled auto-apply config лишь
|
||||
при отсутствии и формирует два disabled Task Scheduler XML для проверки.
|
||||
Он не регистрирует и не включает задачи. Путь trigger известен, но содержимое
|
||||
и его enabled-состояние не читались.
|
||||
- Launcher использует проверенный `C:\Python313\python.exe` с `-B -E -s -S`,
|
||||
проверяет происхождение импортированных модулей, заменяет унаследованный `PATH`,
|
||||
очищает `GIT_*` и фиксирует разрешение `ssh.exe`, `git.exe` и `powershell.exe`
|
||||
в доверенные пути. Задача подготовки сохраняет защищённый `PREPARED` tuple и
|
||||
никогда не отправляет deploy-запрос.
|
||||
- Задача auto-apply и её отдельный конфиг тоже остаются выключенными. Одного
|
||||
`enabled` в preparation config недостаточно для деплоя. Auto-apply разрешён,
|
||||
только если свежий root status точно совпал с tuple, сохранённым предыдущим
|
||||
опросом. После явного согласования владельцем unattended deployment отдельное
|
||||
подтверждение каждого SHA не требуется. Включайте preparation config и задачу
|
||||
только после отдельного разрешения на опрос; auto-apply config и задачу — только
|
||||
после отдельного разрешения на unattended production deployment и выполнения
|
||||
всех backup/restore gate. Отключение любого auto-apply control останавливает
|
||||
будущие автодеплои; уже начатый cold backup/deploy должен завершиться либо быть
|
||||
восстановлен по процедуре.
|
||||
- Ключ `akyldash_release` должен быть отдельным и привязанным к root forced-command
|
||||
dispatcher с OpenSSH `restrict` (или эквивалентным запретом PTY и forwarding),
|
||||
проверкой host key и точным сопоставлением status/deploy-команд. Не добавляйте
|
||||
unrestricted shell или команду в root `authorized_keys`. Существующий ключ и
|
||||
путь rollback остаются отдельным ручным действием владельца. PR не создаёт,
|
||||
не копирует и не устанавливает ключи.
|
||||
- Решите, допустим ли unattended production CD. Сейчас он не включён:
|
||||
`deploy-handoff.yaml` остаётся только status-проверкой и сообщает
|
||||
`NOT DEPLOYED`; обе локальные задачи и оба config gate выключены. Windows-хост
|
||||
не открыт workflow и PR runner. Убедитесь, что root status command установлен
|
||||
из проверенного bootstrap и ключ forced command ограничен.
|
||||
- Checkout на хосте не обязан совпадать с текущим `main`: status сравнивает
|
||||
защищённые root Compose/Caddy файлы и установленную release policy с файлами
|
||||
точного candidate SHA через pinned Gitea Contents API и проверяет Git blob IDs
|
||||
и размеры. Receiver повторяет сравнение до остановки сервисов; cutover повторяет
|
||||
его после backup. Несовпадение требует отдельного проверенного owner bootstrap
|
||||
update; код release или root helper автоматически не выполняется и не обновляется.
|
||||
Текущий `main` и CI/release provenance всё равно перепроверяются перед apply.
|
||||
Endpoint закрывается при неполной странице workflow-run API (более десяти
|
||||
кандидатов или несовпадение count). Перед включением проверьте поведение этого
|
||||
API на установленной версии Gitea.
|
||||
- Подтвердите имена registry packages и минимальные права записи/чтения для
|
||||
`admin/akyldash/backend` и `admin/akyldash/opensearch`; сохраните digest не
|
||||
короче rollback window. Последняя read-only проверка Gitea: main CI run #706
|
||||
завершился успешно, image release run #708 завершился ошибкой из-за отсутствия
|
||||
repository secrets `REGISTRY_USERNAME` и `REGISTRY_PASSWORD`, status-only
|
||||
handoff run #709 был пропущен. Добавьте эти secrets через настройки Gitea;
|
||||
не помещайте значения в Git или логи.
|
||||
- Проверьте, что срок хранения Gitea artifacts покрывает rollback window.
|
||||
Workflow запрашивает хранение небольшого release descriptor и checksum на 90 дней.
|
||||
- Подготовьте датированный очищенный root audit: SHA установленного checkout,
|
||||
Compose services и image IDs, состояние health, пути и режимы volumes,
|
||||
владельца/режим защищённых файлов, сводку listeners и состояние recovery journal.
|
||||
- Подтвердите root-controlled SSH путь для фиксированных deploy/rollback-команд,
|
||||
используя существующую owner SSH-конфигурацию и проверенный host key. Репозиторный
|
||||
`codex` не должен получать общий sudo, членство в Docker group или Docker socket.
|
||||
PR не устанавливает SSH key или root alias.
|
||||
- Если потребуется, установите узко ограниченный read-only Gitea API token в
|
||||
`/etc/akyldash/gitea-api-token`, владелец root, mode `0600`. Запишите только
|
||||
наличие и scope токена, не его значение.
|
||||
- Проверьте origin скачивания Gitea artifacts. При redirect добавляйте только
|
||||
точный HTTPS origin в `/etc/akyldash/gitea-download-origins`, root-owned,
|
||||
mode `0600`. Исправленный helper не должен отправлять этому origin token.
|
||||
- Добавьте одобренные владельцем RU и KY search canary queries в
|
||||
`/etc/akyldash/release-canaries.json`, root-owned, mode `0600`. Убедитесь, что
|
||||
они возвращают ожидаемые непустые результаты; не передавайте тексты корпуса.
|
||||
- Проверьте TLS mode Cloudflare, продление origin certificate и текущую доступность
|
||||
публичных `/review` и search.
|
||||
|
||||
## Backup and recovery gates
|
||||
## Backup и восстановление
|
||||
|
||||
- The complete archive must remain on the owner-controlled Windows destination
|
||||
`D:\Dev\akyldash\.backups`; never stage a full archive on the VPS.
|
||||
- Synthetic Windows protocol checks and a disposable private-NTFS owner-ACL
|
||||
fixture passed. Before relying on the flow, run its checks against the actual
|
||||
D: path and local `.git/info/exclude`, then verify an approved backup is
|
||||
published only after hash and manifest verification.
|
||||
- Confirm D: encryption at rest. Archives contain production secrets and
|
||||
private keys; the protocol encrypts transport but does not encrypt files at
|
||||
rest.
|
||||
- Restore a verified archive on an isolated separate target and check data,
|
||||
SQLite integrity, OpenSearch, and required secrets. Do not use production as
|
||||
a restore test.
|
||||
- Confirm current data/index counts, corpus generation, and migration
|
||||
compatibility. Image rollback does not restore data or undo schema changes.
|
||||
- Set the archive retention period and keep the previous verified archive
|
||||
throughout the rollback window.
|
||||
- Полный архив хранится только на Windows-носителе владельца
|
||||
`D:\Dev\akyldash\.backups`; не размещайте полный архив на VPS.
|
||||
- Synthetic Windows protocol проверки и временная private-NTFS owner-ACL fixture
|
||||
прошли. До использования проверьте реальный путь D: и локальное правило
|
||||
`.git/info/exclude`; затем подтвердите, что одобренный backup публикуется только
|
||||
после проверки hash и manifest.
|
||||
- Подтвердите шифрование D: при хранении. Архив содержит production secrets и
|
||||
private keys; протокол шифрует передачу, но не хранение файлов.
|
||||
- Восстановите проверенный архив на отдельной изолированной цели и проверьте данные,
|
||||
SQLite integrity, OpenSearch и необходимые secrets. Не проверяйте restore на production.
|
||||
- Подтвердите текущие counts данных и индекса, corpus generation и совместимость
|
||||
миграций. Откат образа не восстанавливает данные и не отменяет изменения схемы.
|
||||
- Задайте срок хранения архивов и сохраняйте предыдущий проверенный архив весь
|
||||
rollback window.
|
||||
|
||||
The installer does not create credentials, SSH configuration, canary values,
|
||||
registry access, or new sudo rules. The PR has not been installed or run
|
||||
against production. Windows validation passed 12 synthetic client checks, 8
|
||||
protocol checks, and a disposable real-NTFS private owner-ACL fixture. The
|
||||
actual D: path, SSH, full backup, and separate-target restore remain
|
||||
unverified. Keep deployment disabled until the owner records evidence for
|
||||
each gate above.
|
||||
Installer не создаёт credentials, SSH configuration, canary values, доступ к
|
||||
registry или новые sudo rules. PR не установлен и не запускался на production.
|
||||
Windows validation прошла 12 synthetic client checks, 8 protocol checks и временную
|
||||
private owner-ACL fixture на NTFS. Installer и launcher на Windows не запускались;
|
||||
реальный путь D:, SSH, полный backup и restore на отдельную цель не проверены.
|
||||
Не включайте deployment до фиксации владельцем результатов всех проверок выше.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Готовность к публичному frontend
|
||||
|
||||
Этот документ определяет обязательный результат до начала проектирования и
|
||||
разработки публичной пользовательской системы. Он дополняет
|
||||
[план frontend поисковой СПС](frontend-search-sps-plan.md): тот описывает MVP и
|
||||
спринты frontend, этот — критерий перехода к ним.
|
||||
|
||||
## Правило перехода
|
||||
|
||||
Публичный frontend и его выпуск начинаются, когда выполнены все обязательные
|
||||
пункты этого плана и пройден финальный readiness gate. Внутренняя
|
||||
авторизованная поисковая страница и лаборатория могут развиваться отдельно по
|
||||
явной задаче и используют только реальные существующие API.
|
||||
|
||||
Персональные функции вне этого этапа: закладки, заметки, уведомления, RAG,
|
||||
судебная практика и внешние коммерческие сервисы. Внутренние учетные записи
|
||||
для лаборатории не включают публичную регистрацию.
|
||||
|
||||
## 1. Данные и поисковый индекс
|
||||
|
||||
### Результат
|
||||
|
||||
В локальном OpenSearch доступен воспроизводимо собранный корпус актуальных
|
||||
редакций ЦБД Минюста КР, пригодный для поиска на русском и кыргызском языках.
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
1. Подтвердить для каждого индексируемого документа наличие канонических
|
||||
реквизитов: код, редакция, язык, статус, тип, орган, дата, номер, название
|
||||
и ссылка на официальный источник.
|
||||
2. Зафиксировать правила для документов без текста и одноязычных редакций:
|
||||
они не скрываются и не получают выдуманный перевод.
|
||||
3. Проверить versioned-индекс, mapping, анализаторы RU/KY, полноту актуальных
|
||||
редакций и процедуру безопасной переиндексации с переключением alias.
|
||||
4. Описать и выполнить воспроизводимый сценарий обновления:
|
||||
выгрузка → нормализация → новый индекс → проверка → переключение alias.
|
||||
|
||||
### Критерий приёмки
|
||||
|
||||
Команда может повторить обновление на чистом локальном окружении, проверить
|
||||
количество документов и фрагментов, а затем безопасно переключить поисковый
|
||||
alias без смешивания старого и нового корпуса.
|
||||
|
||||
## 2. Relevance set и качество поиска
|
||||
|
||||
### Результат
|
||||
|
||||
Есть замороженный набор `relevance set v1` из 50 практических запросов:
|
||||
минимум 25 на русском и 25 на кыргызском языках.
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
1. Заполнить рабочую копию из `backend/search/relevance-set-v1.template.json`
|
||||
естественными формулировками пользователей и подтверждёнными
|
||||
`document_code` релевантных действующих актов.
|
||||
2. Подтвердить документы по официальной ЦБД и проверить их наличие в локальном
|
||||
индексе по `document_code`, не оценивая порядок поисковой выдачи. Не включать
|
||||
запросы без установленного эталонного результата.
|
||||
3. Провести независимую вторую проверку языка, формулировки и полного списка
|
||||
кодов. Спорные результаты не включать до согласования.
|
||||
4. После независимой проверки сохранить неизменяемую копию набора, затем
|
||||
оценить собственную выдачу и сохранить baseline `Recall@10`/`MRR@10`. Новые
|
||||
правила ранжирования проверять на том же наборе и сравнивать с baseline.
|
||||
5. Отдельно проверить распространённые сокращения, опечатки и запросы о
|
||||
практическом действии. Правило принимается, только если улучшает
|
||||
подтверждённые запросы и не создаёт ложных срабатываний в негативных
|
||||
сценариях.
|
||||
|
||||
### Критерий приёмки
|
||||
|
||||
Оценщик проходит на полном наборе, выводит метрики и выдачу для каждого
|
||||
запроса; baseline и результаты его повторного запуска воспроизводимы.
|
||||
|
||||
## 3. Справочники и контракт API
|
||||
|
||||
### Результат
|
||||
|
||||
Frontend получает все юридически значимые данные через версионированный
|
||||
OpenAPI-контракт, а не реконструирует их из текста фрагментов.
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
1. Утвердить справочники v1: типы документов, органы принятия, статусы,
|
||||
уровни действия и темы. Для каждого значения определить стабильный код и
|
||||
подписи RU/KY.
|
||||
2. Реализовать и описать OpenAPI для:
|
||||
- `GET /search` — запрос, язык, фильтры, сортировка, серверная пагинация,
|
||||
выдержка и подсветка;
|
||||
- `GET /search/filters` — допустимые значения фильтров;
|
||||
- `GET /documents/{code}` — карточка актуального документа;
|
||||
- `GET /documents/{code}/editions` и
|
||||
`GET /documents/{code}/editions/{edition}` — редакции и их содержимое.
|
||||
3. Зафиксировать единые ответы для пустой выдачи, неизвестного документа,
|
||||
недоступной редакции и ошибки upstream; для документов с одним языком
|
||||
вернуть доступные языки явно.
|
||||
4. Добавить контрактные и интеграционные проверки API на локальном OpenSearch:
|
||||
поиск, фильтры, пагинация, сортировка, документ, редакции и одноязычные
|
||||
акты.
|
||||
|
||||
### Критерий приёмки
|
||||
|
||||
OpenAPI опубликован вместе с backend, тестовый клиент получает реальные данные
|
||||
по всем endpoint без mock-слоя, а результаты поиска открывают актуальный
|
||||
документ и выбранную редакцию.
|
||||
|
||||
## 4. Финальный readiness gate
|
||||
|
||||
Перед началом frontend выполнить и зафиксировать один сквозной сценарий:
|
||||
|
||||
1. Обновить корпус из официальной ЦБД.
|
||||
2. Нормализовать данные и собрать новый versioned-индекс.
|
||||
3. Прогнать проверки индекса и relevance baseline.
|
||||
4. Переключить alias на проверенный индекс.
|
||||
5. Выполнить API-сценарии поиска, фильтрации, просмотра документа и редакции
|
||||
на RU, KY и одноязычном документе.
|
||||
|
||||
Gate считается пройденным, если все проверки успешны, зафиксированы версии
|
||||
backend и индекса, опубликованы известные ограничения и назначен ответственный
|
||||
за юридическую проверку relevance set.
|
||||
|
||||
## Порядок issues
|
||||
|
||||
1. Собрать и независимо проверить relevance set v1.
|
||||
2. Завершить проверку полноты данных и воспроизводимую переиндексацию с alias.
|
||||
3. Утвердить справочники v1.
|
||||
4. Реализовать OpenAPI и интеграционные проверки.
|
||||
5. Провести финальный readiness gate и только затем открыть задачу на
|
||||
проектирование frontend.
|
||||
@@ -1,265 +0,0 @@
|
||||
# План реализации frontend поисковой СПС
|
||||
|
||||
Основание: `docs/Функциональные_возможности_поисковой_СПС.docx`.
|
||||
|
||||
Документ с требованиями описывает не только frontend, но и поиск, юридическую
|
||||
обработку, персональные данные, уведомления и внешние источники. Поэтому
|
||||
frontend следует начинать после появления нормализованной базы, поискового API
|
||||
и API документов.
|
||||
|
||||
Требования необходимо адаптировать под Кыргызскую Республику: в исходном
|
||||
документе используются примеры ТК РФ и деление
|
||||
«федеральный/региональный/муниципальный», которое нельзя переносить без
|
||||
изменений.
|
||||
|
||||
## Задачи до начала frontend-разработки
|
||||
|
||||
### Обязательные для MVP
|
||||
|
||||
1. Нормализовать архив Минюста:
|
||||
- очистить HTML;
|
||||
- выделить структуру документа и редакций;
|
||||
- унифицировать статусы, органы, виды документов и даты;
|
||||
- сохранить ссылки на официальный источник и дату получения;
|
||||
- определить правила отображения документов без текста.
|
||||
2. Подготовить backend API:
|
||||
- `GET /search`;
|
||||
- `GET /search/filters`;
|
||||
- `GET /documents/{code}`;
|
||||
- `GET /documents/{code}/editions`;
|
||||
- `GET /documents/{code}/editions/{edition}`;
|
||||
- описание API в OpenAPI;
|
||||
- серверную пагинацию, фильтрацию и сортировку.
|
||||
3. Развернуть поисковый сервис. Рекомендуемый вариант — self-hosted OpenSearch:
|
||||
- отдельные поля и анализаторы для русского и кыргызского текстов;
|
||||
- русский морфологический анализ;
|
||||
- ICU-нормализация кыргызского текста;
|
||||
- словари синонимов и сокращений;
|
||||
- подсветка совпадений;
|
||||
- индексирование всех редакций.
|
||||
4. Подготовить тестовый набор из 50–100 реальных запросов на русском и
|
||||
кыргызском языках и вручную отметить ожидаемые результаты.
|
||||
5. Утвердить справочники:
|
||||
- виды документов;
|
||||
- органы принятия;
|
||||
- статусы;
|
||||
- уровни действия;
|
||||
- тематический классификатор первой версии.
|
||||
|
||||
OpenSearch имеет встроенный
|
||||
[русский морфологический анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/russian/),
|
||||
поддерживает [синонимы и нечёткий поиск](https://docs.opensearch.org/latest/query-dsl/full-text/match/)
|
||||
и [подсветку результатов](https://docs.opensearch.org/latest/search-plugins/searching-data/highlight).
|
||||
Встроенного кыргызского морфологического анализатора в перечне нет, поэтому
|
||||
нужно отдельно проверить ICU и словари на реальных запросах.
|
||||
[ICU-анализатор](https://docs.opensearch.org/latest/analyzers/language-analyzers/icu/)
|
||||
обеспечивает Unicode-нормализацию, но сам по себе не гарантирует кыргызскую
|
||||
морфологию.
|
||||
|
||||
### Сторонние сервисы, не нужные для MVP
|
||||
|
||||
Их не следует подключать заранее:
|
||||
|
||||
- Keycloak или другой OIDC-провайдер — перед закладками, папками и ролями;
|
||||
- SMTP, Telegram или Web Push — перед «документами на контроле»;
|
||||
- LibreOffice или Gotenberg — перед экспортом в Word, PDF и RTF;
|
||||
- поставщики судебной практики и экспертных комментариев — после проверки
|
||||
лицензий;
|
||||
- источники курсов, календарей и справочных данных — перед соответствующим
|
||||
разделом;
|
||||
- Sentry или аналог — опционально перед публичным запуском.
|
||||
|
||||
## Граница MVP
|
||||
|
||||
MVP — публичная справочно-поисковая система без регистрации и персональных
|
||||
функций.
|
||||
|
||||
В MVP входят:
|
||||
|
||||
- интерфейс на русском и кыргызском языках;
|
||||
- строка полнотекстового поиска;
|
||||
- исправление распространённых опечаток;
|
||||
- базовые синонимы и сокращения;
|
||||
- список результатов с подсвеченными фрагментами;
|
||||
- фильтры по языку, виду документа, органу, статусу и дате;
|
||||
- сортировка по релевантности и дате;
|
||||
- пагинация;
|
||||
- карточка документа;
|
||||
- актуальная редакция, статус и дата актуальности;
|
||||
- переключение между доступными языками;
|
||||
- поиск внутри открытого документа;
|
||||
- список редакций и открытие выбранной редакции;
|
||||
- ссылка на официальный источник и сведения о происхождении данных;
|
||||
- адаптивность, доступность, состояния загрузки и ошибок.
|
||||
|
||||
Сравнение редакций, аккаунты, заметки, уведомления, RAG и судебная практика в
|
||||
MVP не входят.
|
||||
|
||||
Интерфейс не должен предполагать наличие обоих языков. На момент полного
|
||||
скачивания архива распределение следующее:
|
||||
|
||||
- только русский язык — 29 433 документа;
|
||||
- только кыргызский язык — 98 905 документов;
|
||||
- оба языка — 80 930 документов;
|
||||
- нет HTML-текста — 690 документов.
|
||||
|
||||
## Рекомендуемая основа frontend
|
||||
|
||||
Для внутреннего frontend лаборатории и авторизованного поиска используется
|
||||
одна HTML-страница, которую отдаёт существующий Search API, и браузерный
|
||||
JavaScript без сборщика и runtime-зависимостей. Это сохраняет текущую схему
|
||||
развёртывания. Публичный frontend после readiness gate можно оценить отдельно.
|
||||
|
||||
- Next.js App Router и TypeScript;
|
||||
- CSS Modules с BEM-именованием;
|
||||
- дизайн-токены для цветов, отступов, типографики и состояний;
|
||||
- серверный `fetch` и URL-параметры вместо отдельного глобального хранилища;
|
||||
- Playwright для основных пользовательских сценариев;
|
||||
- адаптивный web-интерфейс без отдельного мобильного приложения.
|
||||
|
||||
Next.js App Router поддерживает серверные компоненты, маршрутизацию и
|
||||
TypeScript в стандартной конфигурации. См.
|
||||
[официальную документацию](https://nextjs.org/docs/app).
|
||||
|
||||
## Дизайн-процесс и внешние ориентиры
|
||||
|
||||
При проектировании и проверке интерфейса используются следующие источники:
|
||||
|
||||
- [jakubkrehel/skills](https://github.com/jakubkrehel/skills) — обязательная
|
||||
комплексная проверка интерфейса через `better-interface`, включая UI,
|
||||
типографику, цвета, доступность, layout и тексты;
|
||||
- [UI Skills](https://www.ui-skills.com/) — каталог практик и узких skills,
|
||||
которые подключаются только под конкретную задачу после проверки их
|
||||
содержания и лицензии;
|
||||
- [Refero Styles](https://styles.refero.design/) — библиотека визуальных
|
||||
направлений и примеров `DESIGN.md` для поиска референсов.
|
||||
|
||||
Правила применения:
|
||||
|
||||
1. Визуальное направление «Түндүк» утверждено пользователем. Применять
|
||||
корневой [DESIGN.md](../../DESIGN.md); повторный выбор направления не нужен.
|
||||
2. Не копировать чужую дизайн-систему целиком. Цвета, типографика, плотность и
|
||||
компоненты должны учитывать длинные юридические тексты, два языка и
|
||||
доступность.
|
||||
3. Перенести значения из корневого `DESIGN.md` в дизайн-токены проекта.
|
||||
Не создавать отдельный `frontend/DESIGN.md` или конкурирующие спецификации.
|
||||
4. Дизайн-токены и компоненты Акылдаша являются источником истины. Внешние
|
||||
рекомендации не могут отменять BEM, доступность, требования безопасности и
|
||||
продуктовые ограничения проекта.
|
||||
5. Каждый завершённый пользовательский сценарий проходит `better-interface`
|
||||
review. Перед выпуском MVP выполняется полный review поиска, фильтров и
|
||||
просмотра документа.
|
||||
6. UI Skills используется для точечного поиска решения, а не для одновременного
|
||||
смешивания нескольких визуальных стилей.
|
||||
|
||||
Эти ресурсы используются на этапе проектирования и review и не являются
|
||||
runtime-зависимостями frontend. Регистрация в стороннем SaaS для MVP не нужна.
|
||||
|
||||
## План спринтов MVP
|
||||
|
||||
### Спринт 0 — фундамент, 1 неделя
|
||||
|
||||
- создать `frontend/`;
|
||||
- настроить Next.js, TypeScript, lint и сборку;
|
||||
- применить утверждённую дизайн-систему «Түндүк» из корневого `DESIGN.md`;
|
||||
- установить полный набор `jakubkrehel/skills` для проектных design review;
|
||||
- определить маршруты и типы API;
|
||||
- создать дизайн-токены;
|
||||
- реализовать базовые компоненты: кнопка, поле, селект, статус, карточка,
|
||||
пагинация;
|
||||
- создать общий layout и двуязычную навигацию;
|
||||
- добавить footer с версиями frontend и backend;
|
||||
- подготовить макеты поиска, результатов и документа;
|
||||
- провести первый `better-interface` review макетов;
|
||||
- настроить CI.
|
||||
|
||||
Результат: интерфейсный каркас работает на mock-ответах API.
|
||||
|
||||
### Спринт 1 — быстрый поиск, 2 недели
|
||||
|
||||
- главная страница с поиском;
|
||||
- интеграция с `/search`;
|
||||
- список результатов;
|
||||
- подсветка совпадений;
|
||||
- URL, которым можно поделиться;
|
||||
- переключение RU/KY;
|
||||
- состояния загрузки, отсутствия результатов и ошибки API;
|
||||
- базовая мобильная версия.
|
||||
|
||||
Результат: пользователь может найти документ и открыть результат.
|
||||
|
||||
### Спринт 2 — точный отбор, 2 недели
|
||||
|
||||
- фильтры по реквизитам;
|
||||
- сортировка;
|
||||
- пагинация;
|
||||
- отображение числа результатов;
|
||||
- сброс отдельных и всех фильтров;
|
||||
- сохранение состояния в URL;
|
||||
- доступное управление с клавиатуры;
|
||||
- адаптивная панель фильтров.
|
||||
|
||||
Результат: поддерживается быстрый и реквизитный поиск.
|
||||
|
||||
### Спринт 3 — просмотр документа, 2 недели
|
||||
|
||||
- заголовок, реквизиты, статус и дата актуальности;
|
||||
- безопасное отображение очищенного HTML;
|
||||
- переключение языка;
|
||||
- поиск внутри документа;
|
||||
- навигация по найденным фрагментам;
|
||||
- список редакций;
|
||||
- открытие предыдущей редакции;
|
||||
- ссылка на ЦБД Минюста;
|
||||
- печать средствами браузера.
|
||||
|
||||
Результат: пользователь может проверить текст и его происхождение.
|
||||
|
||||
### Спринт 4 — стабилизация и выпуск, 2 недели
|
||||
|
||||
- сквозные тесты поиска и просмотра;
|
||||
- проверка русских, кыргызских и одноязычных документов;
|
||||
- соответствие WCAG 2.2 AA;
|
||||
- защита от внедрения небезопасного HTML;
|
||||
- проверка производительности;
|
||||
- корректные метаданные страниц;
|
||||
- обработка недоступности API;
|
||||
- production-сборка и развёртывание;
|
||||
- пользовательское тестирование на 10–15 реальных юридических задачах.
|
||||
|
||||
Результат: публичный MVP.
|
||||
|
||||
Оценка frontend-части после готовности API: **9 недель**.
|
||||
|
||||
## Спринты после MVP
|
||||
|
||||
### Спринт 5 — персональные функции
|
||||
|
||||
Базовый вход и смена пароля для внутреннего frontend уже реализованы. Для
|
||||
публичного продукта остаются закладки, заметки, подборки и сохранённые фильтры.
|
||||
|
||||
### Спринт 6 — контроль изменений
|
||||
|
||||
Документы на контроле, подписки на редакции и уведомления.
|
||||
|
||||
### Спринт 7 — юридические связи
|
||||
|
||||
Сравнение редакций, прямые и обратные ссылки, утратившие силу фрагменты.
|
||||
|
||||
### Спринт 8 — практические материалы
|
||||
|
||||
Формы, образцы, инструкции, чек-листы, календари и справочные данные.
|
||||
|
||||
### Спринт 9 — расширенный анализ
|
||||
|
||||
Судебная практика, экспертные комментарии, дерево связей и RAG с обязательными
|
||||
ссылками на источники.
|
||||
|
||||
### Спринт 10 — корпоративные функции
|
||||
|
||||
Роли, журналирование, API, интеграция с СЭД, расширенный экспорт и
|
||||
персонализация.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
@@ -1,9 +1,9 @@
|
||||
# План реализации и приёмки Акылдаш
|
||||
|
||||
Дата актуализации: **2 ноября 2026 года**. Основа сверки: `main` на
|
||||
`7ece39f212b67e497a8dfb360d5d271ab088ef57`. Это единый план работ по продукту,
|
||||
интерфейсу и переходу к production. Он не заменяет эксплуатационные runbooks
|
||||
и не подтверждает состояние живого сервиса.
|
||||
Основа аудита: `main` на `7ece39f212b67e497a8dfb360d5d271ab088ef57`
|
||||
от 2 ноября 2026 года. Документ хранит результаты UI-аудита, критерии
|
||||
интерфейсной приёмки и порядок подготовки production. Целевые возможности
|
||||
и условия публичного MVP ведутся в [функциональности платформы](platform-functionality.md).
|
||||
|
||||
## 1. Статус и границы достоверности
|
||||
|
||||
@@ -43,24 +43,7 @@ deploy-кода. Подтверждённое состояние и ограни
|
||||
|
||||
## 2. Очерёдность работ
|
||||
|
||||
### 2.1 Продукт и качество правовых данных
|
||||
|
||||
1. Поддерживать воспроизводимое получение и нормализацию официальных
|
||||
правовых материалов с сохранением источника, редакции и контрольных сумм.
|
||||
2. Подтвердить полноту и актуальность RU/KY корпуса, правила для отсутствующих
|
||||
текстов и одноязычных редакций; не выдумывать перевод или реквизиты.
|
||||
3. Развивать замороженный relevance set и измерять качество на одной и той же
|
||||
выборке до и после изменений ранжирования.
|
||||
4. Развивать версионируемые API и справочники, сохраняя совместимость
|
||||
существующих клиентов.
|
||||
5. Публичный frontend, персональные функции и RAG остаются отдельными
|
||||
продуктовыми решениями и readiness gates. Сверяться с [обзором проекта](project-overview.md),
|
||||
[планом frontend](frontend-search-sps-plan.md) и
|
||||
[готовностью публичного frontend](frontend-design-readiness-plan.md). Конкурентная
|
||||
гипотеза и сегмент рынка отдельно описаны в
|
||||
[стратегическом сравнении с Токтом](platform-development-plan-toktom-2026-10-24.md).
|
||||
|
||||
### 2.2 Интерфейс: датированный реестр UI-01—UI-33
|
||||
### 2.1 Интерфейс: датированный реестр UI-01—UI-33
|
||||
|
||||
Реестр ниже сохраняет исходные наблюдения аудита **23 октября 2026 года** и
|
||||
критерии приёмки; это не список текущих открытых дефектов. Актуальный план на
|
||||
@@ -241,6 +224,13 @@ source footer и версия кода — `0.3.3`. Его отметка «ож
|
||||
относилась к конкретному pushed SHA и не задаёт статус этого или любого нового
|
||||
PR.
|
||||
|
||||
#### Проверка сценариев публичного MVP
|
||||
|
||||
Каждый завершённый пользовательский сценарий проходит проверку
|
||||
через `better-interface` с оценкой интерфейса, типографики, цвета,
|
||||
доступности, компоновки и текстов. Перед выпуском публичного MVP провести
|
||||
итоговую комплексную проверку поиска, фильтров и просмотра документа.
|
||||
|
||||
#### Общие критерии окон и форм
|
||||
|
||||
- Desktop-окно обычно 480–640 px и не шире viewport; на mobile — отступ 16 px
|
||||
@@ -312,7 +302,7 @@ PR.
|
||||
орнамент в свободную область без влияния на управление, либо получить явное
|
||||
одобрение на изменение `DESIGN.md` и подтвердить его визуальной приёмкой.
|
||||
|
||||
### 2.3 Инфраструктура и переход к production
|
||||
### 2.2 Инфраструктура и переход к production
|
||||
|
||||
Инфраструктурные источники содержали повторяющиеся этапы deployment и
|
||||
устаревшие снимки VPS. В этом плане оставлены только gates; актуальные
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
# План развития «Акылдаш»: конкурентный анализ «Токтом Онлайн»
|
||||
|
||||
Дата: 24 октября 2026 года. Статус: предложение для продуктового решения;
|
||||
не утверждённый бюджет или срок выпуска. Изменения приложения этим документом
|
||||
не выполняются.
|
||||
|
||||
## 1. Вывод и оценка шансов
|
||||
|
||||
**На текущем этапе «Акылдаш» не готов заменить профессиональную СПС целиком.
|
||||
Шанс построить востребованный продукт есть, если начать с ограниченного
|
||||
практического сегмента и доказать качество данных и экономию времени.**
|
||||
|
||||
Копирование всей широты конкурента потребует не только разработки, но и
|
||||
постоянной юридической редакции, получения материалов, сопровождения клиентов
|
||||
и продаж. Рекомендуемая стартовая гипотеза — поиск и контроль норм для
|
||||
небольших юридических и бухгалтерских команд, работающих с ОсОО/ЖЧК,
|
||||
налогообложением и трудовыми вопросами. Наличие отдельных поисковых улучшений
|
||||
по ОсОО и налогам делает этот сегмент удобным для проверки, но само по себе
|
||||
не подтверждает спрос.
|
||||
|
||||
| Сценарий | Оценка | Что должно измениться |
|
||||
| --- | --- | --- |
|
||||
| Быстрая полная замена привычной СПС | Низкие шансы сейчас | Нужны надёжный выпуск, полнота материалов и профессиональные инструменты |
|
||||
| Дополнительный инструмент для одной практической задачи | Наиболее реалистичная гипотеза | Пилот должен показать измеримую пользу без обязательного отказа от прежней системы |
|
||||
| Основное рабочее место небольшой команды | Условно достижимо после пилота | Нужны удержание, обновления, персональная работа и подтверждённая готовность платить |
|
||||
| Массовая платформа с ИИ-консультантом | Долгосрочная гипотеза | Нужны проверяемые ответы, экономика обслуживания и контроль юридических ошибок |
|
||||
|
||||
Проценты вероятности успеха не рассчитываются: нет данных о числе клиентов
|
||||
конкурента, продажах, удержании, нашей команде, бюджете и стоимости привлечения.
|
||||
Цены, скорость и качество поиска систем в одинаковых условиях не сравнивались.
|
||||
Техническая возможность создать функцию не доказывает коммерческое преимущество.
|
||||
|
||||
## 2. Основание и ограничения исследования
|
||||
|
||||
Изучено открытое [руководство «Токтом Онлайн»](https://online.toktom.kg/cms/Topic/273)
|
||||
целиком: портал, учётные записи, продукты, поиск и работа с документами.
|
||||
В таблице ниже номера означают разделы этого руководства. Описание функции
|
||||
подтверждает её документирование, но не результат испытания платной системы.
|
||||
Дата последнего обновления руководства не установлена; старый год в подвале
|
||||
не позволяет определить актуальность отдельных инструкций.
|
||||
|
||||
Отдельная [страница продукта](https://online.toktom.kg/cms/Topic/292)
|
||||
заявляет ежедневное обновление и работу через интернет с синхронизацией
|
||||
персональных данных. Это заявления поставщика; их фактическое выполнение
|
||||
не проверялось. Актуальный тариф на этой странице предлагается запрашивать.
|
||||
|
||||
База сравнения «Акылдаш»: актуальная на повторной сверке `origin/main`, commit
|
||||
`c8d0086a9f20485c5efe6f9d3ccd476dd9f07065` (2 ноября 2026 года). Матрица
|
||||
G01–G25, описание текущих возможностей в разделе 3 и этапы A–F в разделе 6
|
||||
повторно сверены с этой базой. Проверены:
|
||||
|
||||
- [README](../../README.md), [обзор продукта](project-overview.md);
|
||||
- [API](../../backend/search/api.py), [поисковый интерфейс](../../backend/search/review.html),
|
||||
[учётные записи](../../backend/search/auth.py), [запросы](../../backend/search/query.py);
|
||||
- [каталоги](search-catalog-v1.md), [план frontend](frontend-search-sps-plan.md),
|
||||
[readiness gate](frontend-design-readiness-plan.md);
|
||||
- [состояние инфраструктуры и корпуса](../operations/CURRENT_STATE.md),
|
||||
[доступ](../operations/ACCESS.md), а также актуальные реализации Search API,
|
||||
учётных записей и поискового интерфейса.
|
||||
|
||||
Матрица отражает возможности реализации на указанном commit, а не только
|
||||
состояние прежней базы исследования. В частности, в текущем внутреннем контуре
|
||||
администратор может создавать и удалять учётные записи и сбрасывать пароль;
|
||||
публичная регистрация и восстановление по email по-прежнему отсутствуют.
|
||||
Технические версии кода: Backend 0.13.3 (`APP_VERSION` в
|
||||
`backend/search/minjust_opensearch.py`), Frontend 0.3.3 (подвал
|
||||
`backend/search/review.html`), Telegram-бот 0.2.2 (`APP_VERSION` в
|
||||
`tools/telegram-bot/bot.py`). Корневой README указывает Backend 0.13.2;
|
||||
`backend/README.md` содержит более старые Backend 0.13.0 / Frontend 0.2.3.
|
||||
При расхождении использованы версия backend и подвал интерфейса в указанном
|
||||
commit. Эти версии не подтверждают развёртывание соответствующего кода.
|
||||
Живой production, полнота локального корпуса и пользовательская доступность
|
||||
не подтверждены текущими проверками. Нельзя считать функцию доступной клиентам
|
||||
только на основании наличия кода.
|
||||
|
||||
## 3. Что уже есть у нас
|
||||
|
||||
В реализации есть полнотекстовый поиск RU/KY, фильтры по виду, статусу, группе
|
||||
органа, юридической силе и дате; пагинация и сортировка по релевантности/дате.
|
||||
Есть API карточки и редакций, чтение выбранной редакции и языка, оглавление,
|
||||
поиск внутри текста, печать и переход на официальный источник.
|
||||
Это **не нужно разрабатывать заново**.
|
||||
|
||||
Учётные записи SQLite, проверка полномочий на сервере,
|
||||
смена пароля и административные создание, удаление и сброс пароля доступны
|
||||
во внутреннем контуре. Лаборатория собирает оценки выдачи и комментарии
|
||||
проверяющих. Это не публичная регистрация, не восстановление пароля по email,
|
||||
не пользовательские заметки к нормам и не история исследований между устройствами.
|
||||
|
||||
По состоянию, записанному в `CURRENT_STATE.md`, публичный запуск и готовность
|
||||
всего корпуса не подтверждены. В документе есть исторические проверки архива
|
||||
и ограниченного staging; успешная обработка одного документа не доказывает
|
||||
полноту и актуальность базы. RAG в README отмечен как ещё не созданный.
|
||||
|
||||
## 4. Функциональные пробелы
|
||||
|
||||
Обозначения: **нет** — в проверенных API, UI и профильных планах отсутствует
|
||||
готовая возможность; **частично** — есть основа, но сценарий неполон;
|
||||
**не подтверждено** — без аудита корпуса или рабочего сервиса вывод невозможен.
|
||||
«Нет» относится к реализации указанного commit, а не ко всем внешним наработкам.
|
||||
P0 — до публичного пилота; P1 — основное рабочее место; P2 — после спроса;
|
||||
P3 — только при отдельном обосновании.
|
||||
|
||||
| ID | Возможность в руководстве «Токтом» | Состояние «Акылдаш» и необходимое дополнение | Приоритет |
|
||||
| --- | --- | --- | --- |
|
||||
| G01 | Регистрация, восстановление, соцвход (§1.1–1.2) | Частично: внутренние аккаунты есть; публичный onboarding и восстановление по email отсутствуют. Соцвход отложить | P1 / P3 |
|
||||
| G02 | Организация и профиль (§3.1.1) | Частично: есть кабинет, но нет полноценной модели организаций | P2 |
|
||||
| G03 | Счёт, платежи (§3.1.2) | Нет: коммерческий учёт и сверка оплат | P2 |
|
||||
| G04 | Продукты, сроки, промокоды (§3.2.1–3) | Нет: тарифные права и жизненный цикл подписки | P2 |
|
||||
| G05 | Подключение коллег (§3.2.4–5) | Нет: приглашения и права внутри организации; внутренние роли не заменяют это | P2 |
|
||||
| G06 | Настройки выдачи (§3.2.6) | Частично: параметры доступны, серверного сохранения предпочтений нет | P1 |
|
||||
| G07 | Тематические разделы (§3.3.1) | Частично: черновая таксономия есть, публичный тематический фильтр отсутствует | P1 |
|
||||
| G08 | Судебные, справочные, зарубежные материалы (§3.3.1) | Не подтверждено: специализированные полные коллекции и их поиск не показаны; наличие отдельных актов в ЦБД не исключается | P2 / P3 |
|
||||
| G09 | Новые документы (§3.3.3) | Нет отдельной пользовательской ленты поступлений | P1 |
|
||||
| G10 | Избранное, изменения (§3.3.4) | Нет серверного списка и пользовательского контроля изменений | P1 |
|
||||
| G11 | История документов/запросов (§3.3.5) | Нет синхронизированной истории исследования; URL браузера и история входов — другие данные | P1 |
|
||||
| G12 | Обзор обновлений (§3.3.6) | Нет журнала новых, изменённых и отменённых актов для пользователя | P0 / P1 |
|
||||
| G13 | Открытие по коду (§3.4.2) | Частично: API принимает код, отдельного пользовательского сценария нет | P1 |
|
||||
| G14 | Расширенные реквизиты (§3.4.3) | Частично: нужны номер, раздельные поля названия/текста, тематический отбор, множественные значения и поиск без обязательного q | P1 |
|
||||
| G15 | Детальные органы, публикация (§3.4.3.1) | Частично: API ограничен группами органов; нет полного реестра и фильтров по регистрации/источнику публикации | P2 |
|
||||
| G16 | Шаблоны запросов (§3.4.3.2) | Нет именованных сохранённых поисков | P1 |
|
||||
| G17 | Сортировка по органу/виду (§3.5.3) | Нет таких режимов и выбора направления; текущая дата имеет дополнительный приоритет статуса | P2 |
|
||||
| G18 | Word: выдача/документ (§3.5.4; 3.6.6) | Нет DOCX-экспорта; браузерная печать уже есть | P1 |
|
||||
| G19 | Справка и реквизиты (§3.6.1) | Частично: метаданные есть, полная читаемая паспортная карточка требует доработки | P1 |
|
||||
| G20 | Связи, обратные ссылки (§3.6.7–8) | Нет собственного проверенного графа связей и обратного индекса; исходные HTML-ссылки не равнозначны | P1 |
|
||||
| G21 | Предпросмотр, «Связной» (§3.6.9; 13) | Нет контекстного просмотра и связей на уровне конкретной нормы | P2 |
|
||||
| G22 | Графические копии (§3.6.10) | Частично на уровне архива: готовая выдача оригиналов не подтверждена; вложения staging не верифицированы | P2 |
|
||||
| G23 | Сравнение редакций (§3.6.12) | Нет сопоставления двух текстов и выделения изменений; переключатель редакций уже есть | P1 |
|
||||
| G24 | Языковое сравнение (§3.6.14) | Нет параллельного просмотра; переключение RU/KY уже есть | P1 |
|
||||
| G25 | Форум, сообщения (§2; 3.2.7–8) | Нет пользовательского сообщества; рабочий Telegram-бот команды его не заменяет | P3 |
|
||||
|
||||
Редакции, языки, оглавление и печать (§3.6.2–5) в перечень отсутствующих
|
||||
функций не включены. Для сравнительного списка использовано
|
||||
[руководство поставщика](https://online.toktom.kg/cms/Topic/273).
|
||||
|
||||
## 5. Стратегия отличия
|
||||
|
||||
Это гипотезы развития, а не установленные преимущества над конкурентом.
|
||||
Отсутствие описания в его руководстве не доказывает отсутствия функции.
|
||||
|
||||
1. **Проверяемая актуальность.** На каждой норме показывать источник, редакцию,
|
||||
время последней успешной проверки и ограничения данных. Разделять дату
|
||||
получения, принятия, вступления в силу и период применимости.
|
||||
2. **Практический двуязычный поиск.** Измерять RU и KY отдельно на реальных
|
||||
задачах, сокращениях и опечатках. Не обещать кыргызскую морфологию только
|
||||
из-за наличия ICU. Не подменять отсутствующий официальный текст переводом.
|
||||
3. **Рабочая задача вместо набора документов.** Проверить сценарий
|
||||
«найти применимую норму → проверить редакцию → сохранить доказательство →
|
||||
получить сигнал об изменении». Он должен занимать меньше времени у пилота.
|
||||
4. **Объяснение с проверяемыми ссылками.** После надёжного поиска добавить
|
||||
ответы с точными цитатами и открытием фрагмента. При недостатке источников
|
||||
система сообщает об этом; юридические выводы требуют проверки специалистом.
|
||||
|
||||
Удобный интерфейс и низкая цена сами по себе не обеспечат переход клиентов.
|
||||
Открытые источники также не дают автоматического разрешения на любой способ
|
||||
их использования. Права на каждый новый источник проверяются отдельно;
|
||||
копирование базы и редакционных материалов конкурента в план не входит.
|
||||
|
||||
## 6. Последовательность развития и приёмка
|
||||
|
||||
Сроки ниже — предварительные диапазоны для команды из двух разработчиков
|
||||
с регулярным участием юриста и частичным участием дизайнера/эксплуатации.
|
||||
Это предположение для обсуждения, не оценка существующего штата. При иной
|
||||
команде и после обследования данных сроки нужно пересчитать. Переходы
|
||||
определяются результатами, а не истечением недель.
|
||||
|
||||
### Этап A. Данные и воспроизводимый запуск — P0, ориентир 3–6 недель
|
||||
|
||||
Зависимости: состояние официального API, доступный корпус и участие владельца
|
||||
инфраструктуры. Назначить ответственных за данные, эксплуатацию и юридическую
|
||||
проверку. Согласовать правила текущей редакции и обработки неизвестного статуса.
|
||||
|
||||
- Завершить квалификацию источника и полный аудит корпуса; отдельно проверить
|
||||
языки, порядок редакций, контрольные суммы, ошибки, вложения и пропуски.
|
||||
- Запустить воспроизводимый цикл проверки обновлений с журналом изменений
|
||||
и безопасным переключением индекса. Из этого журнала строить G12 и G09.
|
||||
- Выполнить существующий readiness gate, зафиксировать baseline и ограничения.
|
||||
- Подготовить пилотное развёртывание, наблюдение за ошибками, проверенный backup
|
||||
и восстановление в изолированной среде по существующим процедурам.
|
||||
|
||||
Приёмка: завершённый отчёт о полноте с объяснёнными исключениями; повторный
|
||||
запуск не создаёт дубликатов; неудачное обновление сохраняет рабочий индекс;
|
||||
сквозные сценарии RU/KY проходят; восстановление проверено. Рекомендуемая цель
|
||||
актуализации — не более суток от доступности изменения у источника; сначала
|
||||
измерить достижимость и только затем обещать SLA пользователям.
|
||||
|
||||
### Этап B. Поисковое рабочее место — P1, ориентир 4–6 недель
|
||||
|
||||
Зависимость: этап A. Закрыть G07, G13–14, G18–19 и уточнить G06.
|
||||
Использовать готовые просмотр, печать и оглавление; не переписывать их.
|
||||
|
||||
- Утвердить темы и устойчивые идентификаторы; не превращать черновые хеши
|
||||
классификатора в публичный контракт без проверки.
|
||||
- Добавить точный номер и независимые поля поиска; разрешить отбор только
|
||||
по реквизитам; определить семантику AND/OR и множественного выбора.
|
||||
- Сделать паспорт документа, пользовательский сценарий открытия по коду и
|
||||
DOCX с редакцией, языком, источником и временем выгрузки. Не обещать PDF-
|
||||
экспорт сверх печати до подтверждения отдельной потребности.
|
||||
|
||||
Приёмка: 20 задач пилота успешно проходят путь от запроса до нужного фрагмента;
|
||||
документы без языка/текста имеют понятное состояние; файл экспорта воспроизводит
|
||||
выбранную редакцию; новые правила не ухудшают замороженный relevance baseline.
|
||||
|
||||
### Этап C. Возвращение к работе и контроль — P1, ориентир 3–5 недель
|
||||
|
||||
Зависимости: A–B; для внешних пользователей — выбранная модель доступа и
|
||||
проверенное восстановление аккаунта G01. Для закрытого пилота допустимы
|
||||
создаваемые администратором внутренние аккаунты без публичной регистрации.
|
||||
|
||||
Закрыть G06, G10–11, G16: серверные избранное, история и шаблоны; G09/G12
|
||||
показать как пользовательскую ленту. Добавить настройки контроля и доставки
|
||||
уведомлений только после появления надёжного журнала обновлений. Выбор канала
|
||||
и согласие пользователя — отдельное решение; наличие бота команды не означает
|
||||
готовность продуктовых уведомлений.
|
||||
|
||||
Приёмка: сохранённое доступно со второго устройства; данные разных аккаунтов
|
||||
изолированы; удаление/отключение контроля работает; повторная обработка события
|
||||
не дублирует сигнал. Правила хранения истории и удаления аккаунта документированы.
|
||||
|
||||
### Этап D. Анализ нормы — P1/P2, ориентир 5–8 недель
|
||||
|
||||
Зависимости: проверенные редакции, структура фрагментов и метаданные A–B.
|
||||
Закрыть G23–24 и базовую часть G20; затем G21, G22 и G15 по результатам пилота.
|
||||
Для связей сохранять источник, целевую редакцию и уверенность сопоставления.
|
||||
|
||||
Приёмка: юрист проверяет не менее 30 разнообразных пар редакций, включая
|
||||
перенумерацию и перемещение статей; интерфейс отличает техническую правку от
|
||||
содержательной, а неопределённость показывает явно. Параллельный RU/KY-просмотр
|
||||
использует одну редакцию и не выдумывает соответствия. Неуверенные ссылки
|
||||
не выдаются как подтверждённые связи. Текстовый diff не считается юридическим
|
||||
заключением о дате применимости нормы.
|
||||
|
||||
### Этап E. Платный командный продукт — P2, ориентир 4–8 недель
|
||||
|
||||
Зависимость: подтверждённые повторное использование и готовность платить.
|
||||
Закрыть G02–05 и недостающую часть G01. Сначала выбрать одну понятную модель
|
||||
подписки и необходимый канал оплаты; не воспроизводить весь биллинг конкурента.
|
||||
|
||||
Приёмка: организация приглашает и отзывает участника; сервер проверяет права
|
||||
и срок доступа; повторное платёжное событие не создаёт повторное начисление;
|
||||
просроченная подписка не уничтожает сохранённые данные. Стоимость эксплуатации,
|
||||
юридической редакции и поддержки учитывается в экономике тарифа.
|
||||
|
||||
### Этап F. Новые источники и ответы — P2/P3, отдельная оценка
|
||||
|
||||
G08, G17, G25 и оставшиеся расширения включать по измеренному спросу.
|
||||
Для судебной практики сначала исследовать законность получения, полноту,
|
||||
обезличивание и обновляемость; для экспертных материалов — лицензирование
|
||||
и редакционную ответственность. Международные акты проверять в существующем
|
||||
корпусе прежде, чем создавать отдельный загрузчик. Зарубежную базу и форум
|
||||
не ставить на критический путь первого продукта.
|
||||
|
||||
RAG — отдельная разработка после A–D. Начать с поиска доказательств и цитат,
|
||||
затем краткого ответа. Приёмка: замороженный экспертный набор, проверка каждой
|
||||
ссылки и редакции, отказ при недостатке данных и ноль критических неподтверждённых
|
||||
юридических утверждений на приёмочном наборе. Это критерий допуска, а не гарантия
|
||||
безошибочности вне набора. Сравнение качества ИИ конкурентов пока не выполнено.
|
||||
|
||||
## 7. Проверка рыночной гипотезы и условия остановки
|
||||
|
||||
Параллельно этапу A провести 12–15 интервью с юристами и бухгалтерами, включая
|
||||
пользователей действующих СПС. Собирать обезличенные задачи, частоту,
|
||||
потери времени, критичные источники и причины отказа от нового сервиса.
|
||||
Цены сравнивать после получения актуальных коммерческих предложений.
|
||||
|
||||
Предлагаемый пилот: 10–15 специалистов, 4 недели, 20 одинаковых задач с RU/KY
|
||||
и простыми/сложными запросами. С согласия участников сравнить время и долю
|
||||
правильных решений в текущем инструменте и «Акылдаш»; менять порядок систем,
|
||||
чтобы уменьшить эффект обучения. Результат оценивает юрист по первоисточнику.
|
||||
|
||||
Цели ниже — предлагаемые пороги решения, не уже достигнутые показатели:
|
||||
|
||||
| Измерение | Предлагаемый порог | Решение |
|
||||
| --- | --- | --- |
|
||||
| Качество поиска | Recall@10 ≥ 0,90 и MRR@10 ≥ 0,75 на независимо проверенном наборе, отдельно RU и KY | При недостижении улучшать поиск до расширения функций |
|
||||
| Практическая польза | Медианное время правильного выполнения задач меньше минимум на 25% | При отсутствии выигрыша пересмотреть сценарий и сегмент |
|
||||
| Возвращаемость | Не менее 60% активированных участников пользуются продуктом на четвёртой неделе | При провале сначала исследовать причины, затем решать о биллинге |
|
||||
| Готовность платить | Не менее трёх независимых организаций готовы на платный пилот по названной цене | Переходить к этапу E после проверки экономики |
|
||||
| Доверие | Нет критических ошибок редакции/источника в приёмочных задачах | Любая такая ошибка блокирует публичные обещания надёжности |
|
||||
|
||||
Малый пилот помогает выбрать направление, но не даёт оценки доли рынка.
|
||||
Если пользователям необходимы источники, которые мы не можем законно получить,
|
||||
либо стоимость сопровождения превышает приемлемый тариф, сузить сегмент или
|
||||
остановить выбранную модель до дальнейших вложений.
|
||||
|
||||
## 8. Ближайший рабочий цикл
|
||||
|
||||
1. Назначить владельца продукта и юридического редактора; согласовать сегмент.
|
||||
2. Зафиксировать состояние корпуса и readiness gate на актуальной main.
|
||||
3. Провести интервью, собрать задачи и заморозить критерии пилота.
|
||||
4. Превратить этап A и минимальную часть B в отдельные задачи с зависимостями
|
||||
и критериями приёмки. Согласовать связь с существующим планом frontend,
|
||||
не отменяя его gate этим документом.
|
||||
5. После пилота пересчитать сроки, стоимость и порядок C–F; выбрать, какие
|
||||
пробелы действительно мешают продаже, а какие можно отложить.
|
||||
|
||||
Для реализации сохранять существующую архитектуру Python/SQLite/OpenSearch,
|
||||
использовать текущие API и компоненты, добавлять зависимости только по
|
||||
обоснованной необходимости. Изменения проводить отдельными PR с проверками,
|
||||
версией при изменении кода, независимым review и changelog. Production-действия
|
||||
и изменение инфраструктуры требуют действующих процедур доступа и backup.
|
||||
|
||||
---
|
||||
|
||||
Повторная сверка реализации: `main` `c8d0086a9f20485c5efe6f9d3ccd476dd9f07065`.
|
||||
Backend v0.13.3 · Frontend v0.3.3 · Telegram-бот v0.2.2. Это версии
|
||||
исследованного кода; они не подтверждают развёртывание этих версий.
|
||||
@@ -0,0 +1,222 @@
|
||||
# Функциональность платформы Акылдаш
|
||||
|
||||
Этот документ определяет целевой функциональный состав Акылдаша — юридической
|
||||
информационно-аналитической платформы для Кыргызской Республики. Пользователь
|
||||
сможет найти применимую норму, проверить источник и редакцию, сохранить
|
||||
материалы исследования и узнать об изменениях. Существенные юридические выводы
|
||||
проверяет специалист.
|
||||
|
||||
## Текущая реализация
|
||||
|
||||
Сверено с `main` на `871bde1`. В коде доступны полнотекстовый поиск по русским
|
||||
и кыргызским текстам, фильтры по языку, виду акта, статусу, группе органов,
|
||||
юридической силе и дате, сортировка по релевантности и дате, пагинация.
|
||||
Есть карточка документа, выбор редакции и доступного языка, оглавление,
|
||||
поиск в открытом тексте, печать и ссылка на официальный источник.
|
||||
Внутренние учётные записи поддерживают вход, смену пароля, серверную проверку
|
||||
прав, административное создание и удаление аккаунтов и сброс пароля.
|
||||
Лаборатория собирает оценки релевантности и комментарии проверяющих.
|
||||
Публичный запуск и готовность полного корпуса требуют подтверждения по
|
||||
[состоянию инфраструктуры](../operations/CURRENT_STATE.md).
|
||||
Telegram-бот команды описан в [решении о рабочем пространстве](../decisions/001-telegram-workspace-mvp.md).
|
||||
|
||||
## Правовые данные и условия публичного выпуска
|
||||
|
||||
- Получать открытые или лицензированные НПА и иные правовые материалы из
|
||||
официальных источников. Для каждого источника проверить законное основание,
|
||||
условия использования и распространения. Хранить происхождение, ссылку,
|
||||
лицензию, идентификаторы, контрольные суммы, даты получения и последней
|
||||
успешной проверки. Секреты и персональные данные исключать из Git, логов
|
||||
и тестовых наборов. Закрытые юридические документы хранить с поимённым
|
||||
доступом в защищённом хранилище.
|
||||
- Поддерживать регулярное обновление ЦБД Минюста КР с проверкой подлинности,
|
||||
полноты, языков, порядка редакций, вложений и объяснённых пропусков.
|
||||
Нормализовать HTML и структуру до статей и пунктов, сохранять реквизиты
|
||||
и двуязычные коды справочников. У каждого индексируемого документа
|
||||
проверять код, редакцию, язык, статус, тип, орган, дату, номер, название
|
||||
и ссылку на первоисточник. Показывать только доступные официальные
|
||||
языковые версии, явно отмечать отсутствие текста и неизвестный статус.
|
||||
Различать принятие, публикацию, вступление в силу, период применимости,
|
||||
редакцию и дату проверки. Проверять, какая редакция применима на нужную дату.
|
||||
- Вести журнал новых, изменённых и отменённых актов. Повторная обработка
|
||||
изменения идемпотентна. Цель обновления в течение суток после появления
|
||||
у источника измеряется на пилоте до принятия SLA.
|
||||
- Сборку индекса проверять в чистом окружении. Пройти цепочку от обновления
|
||||
корпуса и нормализации до нового версионированного индекса с раздельным
|
||||
анализом RU/KY, проверки mapping, числа документов и фрагментов, затем
|
||||
атомарного переключения alias. При неудаче сохранять рабочий индекс. Приёмка корпуса
|
||||
и выкладка описаны в [процедуре передачи корпуса](../operations/CORPUS_HANDOFF.md)
|
||||
и [плане реализации и приёмки](implementation-roadmap.md).
|
||||
- Собрать `relevance set v1` из 50 практических запросов, минимум по 25 на
|
||||
каждом языке. Юрист сверяет эталонные документы с первоисточником и кодами
|
||||
в индексе. Второй проверяющий подтверждает формулировки и коды. Спорные
|
||||
случаи включаются после согласования. Заморозить набор, воспроизвести
|
||||
Recall@10 и MRR@10 отдельно по языкам, сохранять выдачу для каждого запроса.
|
||||
Сравнивать изменения на том же наборе, проверять опечатки, сокращения,
|
||||
практические запросы и ложные срабатывания. См.
|
||||
[разметку](../../backend/search/RELEVANCE_ANNOTATION.md)
|
||||
и [диагностику поиска](search-relevance-findings-2026-11-03.md).
|
||||
- Утвердить [справочники v1](search-catalog-v1.md) типов, органов, статусов,
|
||||
уровней действия и тем со стабильными кодами и подписями RU/KY. Публичный
|
||||
тематический отбор добавляется после MVP. Проверить опубликованный
|
||||
OpenAPI и реальные интеграции на локальном OpenSearch.
|
||||
Зафиксировать совместимый контракт для поиска, фильтров, пагинации,
|
||||
сортировки, карточки, списка и содержимого редакций. Покрыть пустую
|
||||
выдачу, неизвестный документ, недоступную редакцию и сбой источника.
|
||||
API явно возвращает список доступных языков, в том числе для одноязычных актов.
|
||||
Маршруты и запуск описаны в [Backend README](../../backend/README.md#http-api-v1).
|
||||
- До проектирования публичного frontend зафиксировать сквозное прохождение
|
||||
перечисленных проверок с RU, KY и одноязычным документом, версии backend и
|
||||
индекса, известные ограничения и ответственного за юридическую проверку.
|
||||
До публичного выпуска проверить мониторинг ошибок и восстановление из
|
||||
резервной копии на изолированном окружении. Внутреннее рабочее место и
|
||||
лаборатория используют существующие API и развиваются по своим задачам.
|
||||
|
||||
## Публичный поисковый MVP
|
||||
|
||||
Публичный адаптивный веб-интерфейс на русском и кыргызском языках. Доступ к
|
||||
базовому поиску и чтению открыт без регистрации.
|
||||
|
||||
- **Поиск.** Свободный полнотекстовый запрос, раздельный анализ двух языков,
|
||||
морфология русского текста, проверенные синонимы, сокращения и опечатки.
|
||||
Для кыргызского текста отдельно проверять качество нормализации и словарей.
|
||||
Выдача показывает число результатов, подсвеченные фрагменты и пагинацию.
|
||||
- **Отбор.** Фильтры по языку, виду документа, статусу, органу, юридической
|
||||
силе и дате. Сортировка по релевантности и дате. Сброс отдельных и всех
|
||||
фильтров, сохранение состояния поиска в URL для повторного открытия.
|
||||
- **Документ.** Заголовок и реквизиты, актуальная редакция, юридический
|
||||
статус и дата актуальности, происхождение и официальный источник.
|
||||
Доступны оглавление, поиск по тексту с подсветкой и переходом между
|
||||
совпадениями, выбор доступного языка, список редакций, открытие предыдущей
|
||||
редакции и печать.
|
||||
Одноязычные документы и карточки без текста имеют явное состояние.
|
||||
- **Качество интерфейса.** Состояния загрузки, пустой выдачи и ошибок,
|
||||
клавиатурная навигация, адаптивные фильтры и длинные правовые тексты,
|
||||
WCAG 2.2 AA, безопасное отображение очищенного HTML, проверка
|
||||
производительности, метаданные страниц и версии frontend/backend в
|
||||
подвале. Дизайн опирается на [Түндүк](../../DESIGN.md). Перед выпуском
|
||||
пройти сквозные проверки поиска и чтения на 10–15 практических задачах.
|
||||
|
||||
## Поисковое рабочее место
|
||||
|
||||
- Реквизитный поиск с отдельными полями названия и текста, точным номером,
|
||||
открытием по коду и поиском без текстового запроса. Поддержать множественный
|
||||
выбор и определить правила AND/OR. Тематический отбор строить на
|
||||
иерархическом рубрикаторе КР с проверенными стабильными кодами.
|
||||
- Расширить паспорт документа. По мере готовности источников отображать
|
||||
точный орган принятия, территорию действия по классификации КР, данные
|
||||
регистрации и источник публикации. По результатам пилота добавить отбор
|
||||
по этим реквизитам и оценить сортировку по органу, виду, дате изменения
|
||||
и юридической силе с выбором направления.
|
||||
- Экспорт выбранной редакции, результатов и подборок в DOCX с сохранением
|
||||
структуры и выделений. Включать язык, редакцию, источник и время выгрузки.
|
||||
При копировании фрагмента указывать первоисточник. PDF сверх браузерной
|
||||
печати и RTF зависят от подтверждённой потребности.
|
||||
- Открывать несколько документов одновременно, сохранять контекст во вкладках
|
||||
и показывать материалы бок о бок. Приёмка рабочего места включает 20
|
||||
практических задач от запроса до нужного фрагмента, проверку экспорта
|
||||
нужной редакции и отсутствие ухудшения на замороженном наборе поиска.
|
||||
|
||||
## Личный кабинет и контроль изменений
|
||||
|
||||
- Выбрать модель пользовательского доступа. Для закрытого пилота подходят
|
||||
внутренние аккаунты, выданные администратором. Для персональных функций
|
||||
внешних пользователей определить регистрацию, подтверждение аккаунта и
|
||||
проверяемое восстановление доступа по email.
|
||||
- Хранить на сервере избранное, историю запросов и документов, именованные
|
||||
поиски и шаблоны фильтров, параметры выдачи, закладки и заметки к статьям и
|
||||
пунктам. Поддержать папки и подборки по проектам, доступ по приглашению
|
||||
или ссылке с проверкой прав и экспорт подборок. Персонализировать быстрые
|
||||
ссылки, избранные разделы и панель инструментов.
|
||||
- Публиковать ленту поступлений и обзор новых, изменённых и отменённых
|
||||
актов. Пользователь ставит документ или отдельную норму на контроль и
|
||||
получает сигнал об изменении редакции либо о новой связанной судебной
|
||||
практике после появления соответствующей коллекции.
|
||||
- Выбрать каналы уведомлений из email, Telegram и Web Push по результатам
|
||||
пилота. Предусмотреть согласие, настройки частоты и отключение.
|
||||
Проверять синхронизацию на втором устройстве, изоляцию аккаунтов,
|
||||
отзыв доступа, отключение и удаление контроля, отсутствие дубликатов
|
||||
событий. Документировать сроки хранения истории и удаление аккаунта.
|
||||
|
||||
## Юридическое сравнение и связи
|
||||
|
||||
- Сравнивать редакции бок о бок с подсветкой добавлений, исключений,
|
||||
перемещений и перенумерации. Различать технические и содержательные правки,
|
||||
отмечать утратившие силу фрагменты и показывать неопределённость.
|
||||
Юрист проверяет минимум 30 разнообразных пар. Юридическую применимость
|
||||
оценивать по реквизитам и источникам.
|
||||
- Показывать русский и кыргызский тексты одной редакции параллельно,
|
||||
сопоставляя только подтверждённые фрагменты.
|
||||
- Хранить прямые и обратные связи с другими нормами, актами, разъяснениями
|
||||
и судебной практикой. Показывать контекстный предпросмотр, цепочку норм
|
||||
и дерево или граф. Фиксировать источник, целевую редакцию и уверенность
|
||||
сопоставления. Отдельно обозначать непроверенные связи.
|
||||
- Выдавать проверенные вложения, сканы и графические копии при наличии
|
||||
в источнике и прав на распространение.
|
||||
|
||||
## Практические материалы и проверяемые ответы
|
||||
|
||||
- По результатам пилота оценить включение правоприменительной практики КР,
|
||||
писем и позиций госорганов, редакционных пояснений и экспертных
|
||||
комментариев. Проверять полноту, обновляемость, обезличивание и права
|
||||
на использование. Сначала выяснить покрытие международных актов
|
||||
существующим корпусом.
|
||||
- Подготовить типовые договоры, заявления, претензии и акты с заполняемыми
|
||||
полями и подсказками. Добавить инструкции, готовые решения, чек-листы,
|
||||
сроки и ответственные органы. Справочный раздел включает производственный
|
||||
календарь, сроки отчётности, ставки, курсы, индексы и реквизиты госорганов.
|
||||
Для справочных данных определить источник и частоту обновления.
|
||||
- Построить базу знаний для RAG поверх проверенного корпуса и структуры
|
||||
редакций. Сначала выдавать доказательства и точные цитаты с переходом
|
||||
к фрагменту, затем формировать краткий ответ. Отделять найденные факты
|
||||
от вывода, сообщать о недостатке данных и проверять актуальность нормы.
|
||||
Приёмка на замороженном экспертном наборе проверяет каждую ссылку,
|
||||
цитату и редакцию и требует отсутствия критических неподтверждённых
|
||||
юридических утверждений.
|
||||
|
||||
## Организации и интеграции
|
||||
|
||||
- Профили организаций, приглашение и удаление участников, роли и права
|
||||
юриста, бухгалтера, кадровика и администратора. Сервер проверяет доступ
|
||||
к материалам и срок действия прав. Журналировать административные и
|
||||
юридически значимые действия с соблюдением правил хранения данных.
|
||||
- Тарифы и права по продуктам, срок подписки, счета, платежи и сверка.
|
||||
Начать с одной модели подписки и канала оплаты после подтверждения
|
||||
спроса. Повторное событие обрабатывается один раз. Данные сохраняются
|
||||
при истечении подписки. Промокоды зависят от выбранной модели продаж.
|
||||
- Корпоративный API, структурированные выгрузки для дальнейшей обработки
|
||||
и интеграция с системами электронного документооборота с проверкой доступа.
|
||||
- Социальный вход, самостоятельные мобильный и десктопный клиенты,
|
||||
зарубежные коллекции, форум и пользовательские сообщения требуют
|
||||
отдельного решения о спросе, лицензиях и стоимости сопровождения.
|
||||
|
||||
## Порядок развития и проверка спроса
|
||||
|
||||
Данные и готовность выпуска предшествуют публичному MVP. Далее развивать
|
||||
поисковое рабочее место, личные функции и контроль изменений. Сравнение,
|
||||
связи и ответы зависят от проверенных редакций и структуры норм. Командные
|
||||
тарифы требуют доказанного повторного использования и готовности платить.
|
||||
Инфраструктурные и визуальные критерии ведутся в
|
||||
[плане реализации и приёмки](implementation-roadmap.md).
|
||||
|
||||
Исходная гипотеза из анализа «Токтом Онлайн» от 24 октября 2026 года —
|
||||
пилот для небольших юридических и бухгалтерских команд по ОсОО/ЖЧК,
|
||||
налогообложению и труду. Предложено провести 12–15 интервью, затем
|
||||
четырёхнедельный пилот с 10–15 специалистами на 20 обезличенных RU/KY-задачах.
|
||||
Юрист проверяет правильность по первоисточникам. Для каждого языка предложены
|
||||
пороги Recall@10 ≥ 0,90 и MRR@10 ≥ 0,75. Предложенный выигрыш медианного времени
|
||||
правильного решения составляет ≥ 25%, возвращаемость на четвёртой неделе
|
||||
составляет ≥ 60%. На пилоте также нужно подтвердить готовность минимум трёх
|
||||
организаций платить названную цену и отсутствие критических ошибок редакции
|
||||
или источника в приёмочных задачах. Пороги, сроки реализации и бюджет требуют
|
||||
подтверждения. Сужать сегмент, если нужные материалы недоступны законно или
|
||||
расходы на редакцию, поддержку и эксплуатацию делают тариф нежизнеспособным.
|
||||
|
||||
История требований сохранена в Git на `871bde1`. В основу этого документа
|
||||
легли Word-ТЗ поисковой СПС, обзор проекта, планы поискового frontend и
|
||||
готовности к нему, а также датированный анализ «Токтом Онлайн» (матрица
|
||||
G01–G25 и этапы A–F). Оценки конкурента относятся к датированному исследованию.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.13.4 · Frontend v0.3.4
|
||||
@@ -1,47 +0,0 @@
|
||||
# Обзор проекта
|
||||
|
||||
## Назначение
|
||||
|
||||
Акылдаш — юридическая информационно-аналитическая платформа. Она должна помочь
|
||||
пользователю находить применимые нормы и материалы, понимать их актуальность и
|
||||
получать ответ со ссылками на проверяемые первоисточники. Платформа не заменяет
|
||||
профессиональное юридическое заключение.
|
||||
|
||||
## Основные области
|
||||
|
||||
1. Получение открытых или лицензированных законов, актов и иных правовых
|
||||
источников.
|
||||
2. Очистка, нормализация, связывание редакций и сохранение происхождения данных.
|
||||
3. Хранение структурированных документов и поисковых индексов.
|
||||
4. Формирование базы знаний и RAG с обязательными ссылками на источники.
|
||||
5. Backend для доступа к данным и функциям платформы.
|
||||
6. Frontend для поиска, анализа и работы с результатами.
|
||||
7. Инструменты рабочего окружения команды, включая Telegram-бота.
|
||||
|
||||
Физическая структура каждой области появится вместе с её первой задачей. До
|
||||
этого список служит картой продукта, а не обещанием заранее выбранной
|
||||
архитектуры.
|
||||
|
||||
## Обязательные принципы
|
||||
|
||||
- Источник, дата получения, редакция и лицензия документа должны быть
|
||||
прослеживаемыми.
|
||||
- Ответ системы должен отделять найденный факт от вывода модели и ссылаться на
|
||||
конкретный первоисточник.
|
||||
- Актуальность правовых данных должна проверяться до использования в ответе.
|
||||
- Существенные юридические выводы проверяет человек.
|
||||
- Секреты и персональные данные не попадают в Git, логи и тестовые наборы.
|
||||
- Данные загружаются только при наличии законного основания и с соблюдением
|
||||
условий источника.
|
||||
|
||||
## Правовая защита проекта
|
||||
|
||||
Договоры, заявки, материалы об интеллектуальной собственности и другие
|
||||
закрытые юридические документы следует хранить отдельно от исходного кода: в
|
||||
закрытом репозитории или защищённом документном хранилище. Доступ выдаётся
|
||||
поимённо и только тем, кому он нужен. В основном репозитории можно хранить
|
||||
несекретный реестр документов, правила доступа и ссылки на место хранения.
|
||||
|
||||
---
|
||||
|
||||
Акылдаш · Telegram-бот v0.2.2 · Backend v0.12.2 · Frontend v0.1.2
|
||||
Binary file not shown.
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -18,20 +19,23 @@ from pathlib import Path
|
||||
|
||||
from archive import verify_external_archive
|
||||
from external_session import (ArchiveFrameWriter, read_challenge, read_response, read_result,
|
||||
MAX_ARCHIVE_BYTES, receive_archive, write_ack, write_request)
|
||||
MAX_ARCHIVE_BYTES, receive_archive, validate_request, write_ack, write_request)
|
||||
|
||||
DEFAULT_REPOSITORY = Path(r"D:\Dev\akyldash")
|
||||
DEFAULT_DEPLOY_SSH_ALIAS = "akyldash_release"
|
||||
TRIGGER_CONFIG = Path(r"C:\ProgramData\Akyldash\release-trigger.json")
|
||||
AUTO_APPLY_CONFIG = Path(r"C:\ProgramData\Akyldash\release-auto-apply.json")
|
||||
PREPARED_STATE_FILE = Path(r"C:\ProgramData\Akyldash\prepared-release.json")
|
||||
RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status"
|
||||
MAX_STATUS_BYTES = 1024
|
||||
STATUS_TIMEOUT_SECONDS = 180
|
||||
NOT_READY_REASONS = {
|
||||
"recovery_journal", "host_checkout_stale", "no_current_main_release", "main_advanced",
|
||||
"already_applied",
|
||||
"recovery_journal", "runtime_config_changed", "release_policy_changed",
|
||||
"no_current_main_release", "main_advanced", "already_applied",
|
||||
}
|
||||
MAX_ERROR_BYTES = 8192
|
||||
_WINDOWS = os.name == "nt"
|
||||
DEFAULT_SSH = str(Path(r"C:\Windows\System32\OpenSSH\ssh.exe")) if _WINDOWS else "ssh"
|
||||
|
||||
|
||||
def _reparse(path: Path) -> bool:
|
||||
@@ -79,8 +83,9 @@ $allowed = @($owner, 'S-1-5-18', 'S-1-5-32-544')
|
||||
$aclOwner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
if ($aclOwner -notin $allowed) { exit 6 }
|
||||
$ownerAllowed = $false
|
||||
foreach ($rule in $acl.Access) {
|
||||
$sid = $rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $sid -notin $allowed) { exit 2 }
|
||||
if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { exit 3 }
|
||||
if ($sid -eq $owner) { $ownerAllowed = $true }
|
||||
@@ -213,7 +218,7 @@ def validate_status_response(payload: bytes) -> dict | None:
|
||||
or value["release"] is not None):
|
||||
raise ValueError("release status not-ready response is invalid")
|
||||
return None
|
||||
if value["state"] != "candidate" or value["reason"] is not None:
|
||||
if value["state"] != "prepared" or value["reason"] is not None:
|
||||
raise ValueError("release status state is invalid")
|
||||
release = value["release"]
|
||||
if not isinstance(release, dict) or set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
@@ -223,7 +228,59 @@ def validate_status_response(payload: bytes) -> dict | None:
|
||||
or not isinstance(release["descriptor_sha256"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", release["descriptor_sha256"])):
|
||||
raise ValueError("release status candidate identity is invalid")
|
||||
return {"version": 1, "action": "release", **release}
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def write_prepared_request(request: dict, path: Path = PREPARED_STATE_FILE) -> None:
|
||||
validate_request(request)
|
||||
if _reparse(path.parent) or not path.parent.is_dir() or _reparse(path):
|
||||
raise ValueError("prepared release state path is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
if path.exists():
|
||||
check_private_acl(path)
|
||||
value = {
|
||||
"schema_version": 1,
|
||||
"prepared_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
||||
temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
check_private_acl(temporary)
|
||||
os.replace(temporary, path)
|
||||
check_private_acl(path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_prepared_request(path: Path = PREPARED_STATE_FILE) -> dict:
|
||||
if _reparse(path.parent) or _reparse(path) or not path.parent.is_dir() or not path.is_file():
|
||||
raise ValueError("prepared release state is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
raw = path.read_bytes()
|
||||
if not raw or len(raw) > MAX_STATUS_BYTES:
|
||||
raise ValueError("prepared release state is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("prepared release state is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "prepared_at", "release"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or not isinstance(value["prepared_at"], str) or not isinstance(value["release"], dict)):
|
||||
raise ValueError("prepared release state has an unsupported schema")
|
||||
release = value["release"]
|
||||
if set(release) != {"run_id", "commit", "descriptor_sha256"}:
|
||||
raise ValueError("prepared release tuple is invalid")
|
||||
request = {"version": 1, "action": "release", **release}
|
||||
validate_request(request)
|
||||
return request
|
||||
|
||||
|
||||
def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
|
||||
@@ -255,7 +312,31 @@ def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict:
|
||||
return value
|
||||
|
||||
|
||||
def query_release_status(*, ssh: str = "ssh", ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
|
||||
def load_auto_apply_config(path: Path = AUTO_APPLY_CONFIG) -> dict:
|
||||
if not _WINDOWS:
|
||||
raise ValueError("automatic release apply is available only on Windows")
|
||||
if (_reparse(path) or _reparse(path.parent) or not path.is_file() or not path.parent.is_dir()):
|
||||
raise ValueError("automatic release apply configuration is missing or unsafe")
|
||||
check_private_acl(path.parent)
|
||||
check_private_acl(path)
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError("automatic release apply configuration is unreadable") from error
|
||||
if not raw or len(raw) > 1024:
|
||||
raise ValueError("automatic release apply configuration is empty or oversized")
|
||||
try:
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("automatic release apply configuration is invalid JSON") from error
|
||||
if (not isinstance(value, dict) or set(value) != {"schema_version", "enabled"}
|
||||
or type(value["schema_version"]) is not int or value["schema_version"] != 1
|
||||
or type(value["enabled"]) is not bool):
|
||||
raise ValueError("automatic release apply configuration is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def query_release_status(*, ssh: str = DEFAULT_SSH, ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
if ssh_alias != DEFAULT_DEPLOY_SSH_ALIAS:
|
||||
@@ -369,7 +450,7 @@ def receive_backup(reader, writer, *, repository: Path, request: dict, expected_
|
||||
raise
|
||||
|
||||
|
||||
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
|
||||
def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
@@ -407,19 +488,61 @@ def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: st
|
||||
raise
|
||||
|
||||
|
||||
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, ssh: str = "ssh") -> None:
|
||||
def run_release_once(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
if not config["enabled"]:
|
||||
print("Release trigger is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is ready; deployment not started.")
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"PASS prepared release {request['commit']} (run {request['run_id']}); owner confirmation is required to deploy.")
|
||||
|
||||
|
||||
def run_auto_apply_once(*, config_file: Path = TRIGGER_CONFIG,
|
||||
auto_apply_file: Path = AUTO_APPLY_CONFIG,
|
||||
prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH) -> None:
|
||||
config = load_trigger_config(config_file)
|
||||
auto_config = load_auto_apply_config(auto_apply_file)
|
||||
if not config["enabled"] or not auto_config["enabled"]:
|
||||
print("Automatic apply is disabled; no SSH connection opened.")
|
||||
return
|
||||
request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
if request is None:
|
||||
print("No verified current-main release is prepared; no deployment started.")
|
||||
return
|
||||
if _reparse(prepared_file):
|
||||
raise ValueError("prepared release state is unsafe")
|
||||
if not prepared_file.exists():
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
previous = read_prepared_request(prepared_file)
|
||||
if previous != request:
|
||||
write_prepared_request(request, prepared_file)
|
||||
print(f"Prepared release {request['commit']}; automatic apply requires a later poll.")
|
||||
return
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
|
||||
def apply_prepared_release(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE,
|
||||
ssh: str = DEFAULT_SSH, confirm=input) -> None:
|
||||
if not sys.stdin.isatty():
|
||||
raise ValueError("applying a prepared release requires an interactive owner confirmation")
|
||||
request = read_prepared_request(prepared_file)
|
||||
answer = confirm(f"Type APPLY {request['commit']} to start the separately approved production release: ")
|
||||
if answer != f"APPLY {request['commit']}":
|
||||
print("Prepared release was not applied.")
|
||||
return
|
||||
config = load_trigger_config(config_file)
|
||||
run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"])
|
||||
|
||||
|
||||
def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH,
|
||||
ssh_alias: str = "akyldash_vps") -> None:
|
||||
if shutil.which(ssh) is None:
|
||||
raise ValueError("OpenSSH client is unavailable")
|
||||
@@ -470,7 +593,9 @@ def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh",
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true", help="query the fixed status endpoint and deploy at most one verified release")
|
||||
parser.add_argument("--once", action="store_true", help="prepare at most one verified release; never deploy")
|
||||
parser.add_argument("--apply-prepared", action="store_true", help="deploy the prepared tuple after interactive owner confirmation")
|
||||
parser.add_argument("--auto-apply-prepared", action="store_true", help="apply an already prepared tuple only when the separate owner auto-apply gate is enabled")
|
||||
parser.add_argument("--rollback", action="store_true")
|
||||
parser.add_argument("--data-compatible", action="store_true")
|
||||
parser.add_argument("--run-id")
|
||||
@@ -480,11 +605,21 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.once:
|
||||
if (args.rollback or args.data_compatible or any(
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" or any(
|
||||
(args.run_id, args.commit, args.descriptor_sha256))
|
||||
or args.ssh_alias != "akyldash_vps"):
|
||||
):
|
||||
raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias")
|
||||
run_release_once()
|
||||
elif args.apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--apply-prepared accepts no release tuple or rollback flags")
|
||||
apply_prepared_release()
|
||||
elif args.auto_apply_prepared:
|
||||
if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps"
|
||||
or any((args.run_id, args.commit, args.descriptor_sha256))):
|
||||
raise ValueError("--auto-apply-prepared accepts no release tuple or rollback flags")
|
||||
run_auto_apply_once()
|
||||
elif args.rollback:
|
||||
if not args.data_compatible or any((args.run_id, args.commit, args.descriptor_sha256)):
|
||||
raise ValueError("rollback requires --data-compatible and accepts no release tuple")
|
||||
|
||||
@@ -203,6 +203,9 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertIn("System.IO.DriveInfo", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn("Get-Volume", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetOwner(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$rule.IdentityReference.Value", run_mock.call_args.args[0][-1])
|
||||
self.assertNotIn(".Translate(", run_mock.call_args.args[0][-1])
|
||||
self.assertIn("$aclOwner -notin $allowed", run_mock.call_args.args[0][-1])
|
||||
|
||||
def test_private_acl_rejects_an_unapproved_acl_owner(self):
|
||||
@@ -227,8 +230,8 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertTrue(all(call.args[1].startswith("preflight-") for call in preflight.call_args_list))
|
||||
popen.assert_not_called()
|
||||
|
||||
def test_status_response_accepts_only_the_fixed_candidate_tuple(self):
|
||||
response = {"schema_version": 1, "state": "candidate", "reason": None,
|
||||
def test_status_response_accepts_only_the_fixed_prepared_tuple(self):
|
||||
response = {"schema_version": 1, "state": "prepared", "reason": None,
|
||||
"release": {"run_id": "302", "commit": "a" * 40,
|
||||
"descriptor_sha256": "b" * 64}}
|
||||
self.assertEqual(receive_external.validate_status_response(json.dumps(response).encode()),
|
||||
@@ -239,6 +242,10 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertIsNone(receive_external.validate_status_response(
|
||||
b'{"schema_version":1,"state":"not_ready","reason":"already_applied","release":null}'
|
||||
))
|
||||
with self.assertRaisesRegex(ValueError, "state"):
|
||||
receive_external.validate_status_response(
|
||||
json.dumps({**response, "state": "candidate"}).encode()
|
||||
)
|
||||
|
||||
def test_status_response_rejects_extra_or_duplicate_fields(self):
|
||||
payload = (b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",'
|
||||
@@ -266,7 +273,7 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertIn("ClearAllForwardings=yes", command)
|
||||
self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL)
|
||||
|
||||
def test_once_disabled_is_noop_and_candidate_reuses_existing_release_flow(self):
|
||||
def test_once_only_prepares_and_separate_interactive_apply_requires_exact_confirmation(self):
|
||||
config = {"schema_version": 1, "enabled": False,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY),
|
||||
"ssh_alias": receive_external.DEFAULT_DEPLOY_SSH_ALIAS}
|
||||
@@ -276,20 +283,78 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
with patch.object(receive_external, "_WINDOWS", True), \
|
||||
patch.object(receive_external, "check_private_acl"), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_release_once(config_file=path)
|
||||
patch.object(receive_external, "run_release") as deploy, \
|
||||
patch.object(receive_external, "PREPARED_STATE_FILE", Path(directory) / "prepared.json"):
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
config["enabled"] = True
|
||||
path.write_text(json.dumps(config), encoding="utf-8")
|
||||
candidate = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
query.return_value = candidate
|
||||
receive_external.run_release_once(config_file=path)
|
||||
deploy.assert_called_once_with(
|
||||
candidate, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release")
|
||||
prepared = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
query.return_value = prepared
|
||||
receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json")
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(Path(directory) / "prepared.json"), prepared)
|
||||
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=False):
|
||||
with self.assertRaisesRegex(ValueError, "interactive owner confirmation"):
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
)
|
||||
with patch.object(receive_external.sys.stdin, "isatty", return_value=True), \
|
||||
patch.object(receive_external, "run_release") as apply:
|
||||
receive_external.apply_prepared_release(
|
||||
config_file=path, prepared_file=Path(directory) / "prepared.json",
|
||||
confirm=lambda prompt: f"APPLY {'a' * 40}",
|
||||
)
|
||||
apply.assert_called_once_with(
|
||||
prepared, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release")
|
||||
|
||||
def test_auto_apply_requires_a_prior_matching_prepared_poll_and_separate_gate(self):
|
||||
previous = {"version": 1, "action": "release", "run_id": "301",
|
||||
"commit": "a" * 40, "descriptor_sha256": "b" * 64}
|
||||
latest = {"version": 1, "action": "release", "run_id": "302",
|
||||
"commit": "c" * 40, "descriptor_sha256": "d" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
prepared_file = root / "prepared.json"
|
||||
trigger_file = root / "trigger.json"
|
||||
auto_file = root / "auto.json"
|
||||
with patch.object(receive_external, "check_private_acl"):
|
||||
receive_external.write_prepared_request(previous, prepared_file)
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True,
|
||||
"repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": "akyldash_release"}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "query_release_status", return_value=latest) as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
query.assert_called_once()
|
||||
deploy.assert_not_called()
|
||||
self.assertEqual(receive_external.read_prepared_request(prepared_file), latest)
|
||||
|
||||
query.reset_mock()
|
||||
query.return_value = latest
|
||||
receive_external.run_auto_apply_once(
|
||||
config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file,
|
||||
)
|
||||
deploy.assert_called_once_with(
|
||||
latest, repository=receive_external.DEFAULT_REPOSITORY,
|
||||
ssh="ssh", ssh_alias="akyldash_release",
|
||||
)
|
||||
|
||||
def test_auto_apply_disabled_does_not_open_ssh(self):
|
||||
with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True}), \
|
||||
patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": False}), \
|
||||
patch.object(receive_external, "query_release_status") as query, \
|
||||
patch.object(receive_external, "run_release") as deploy:
|
||||
receive_external.run_auto_apply_once()
|
||||
query.assert_not_called()
|
||||
deploy.assert_not_called()
|
||||
|
||||
def test_windows_trigger_templates_are_fixed_and_disabled(self):
|
||||
root = Path(__file__).resolve().parents[1] / "deploy"
|
||||
@@ -306,7 +371,16 @@ class ExternalReceiverTests(unittest.TestCase):
|
||||
self.assertEqual(task.findtext("t:Principals/t:Principal/t:RunLevel", namespaces=ns), "LeastPrivilege")
|
||||
self.assertEqual(
|
||||
task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'"C:\\Program Files\\Akyldash\\receive_external.py" --once',
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --once',
|
||||
)
|
||||
auto_config = json.loads((root / "windows-release-auto-apply.example.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(auto_config, {"schema_version": 1, "enabled": False})
|
||||
auto_task = ET.parse(root / "windows-release-auto-apply.example.xml").getroot()
|
||||
self.assertEqual(auto_task.findtext("t:Settings/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(auto_task.findtext("t:Triggers/t:CalendarTrigger/t:Enabled", namespaces=ns), "false")
|
||||
self.assertEqual(
|
||||
auto_task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns),
|
||||
'-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --auto-apply-prepared',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -146,15 +146,21 @@ def deploy_external(
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256):
|
||||
raise ValueError("release descriptor digest is invalid")
|
||||
from release_handoff import latest_main_sha
|
||||
if latest_main_sha(ROOT) != manifest["commit"]:
|
||||
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
|
||||
from release_source import require_runtime_compatibility
|
||||
|
||||
def verify_release_still_current() -> None:
|
||||
if latest_main_sha(ROOT) != manifest["commit"]:
|
||||
raise ValueError("main advanced after the external backup; refusing a stale release cutover")
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT,
|
||||
policy_file=Path("/etc/akyldash/release-policy.json"),
|
||||
)
|
||||
|
||||
verify_release_still_current()
|
||||
if not isinstance(current_before_stop, dict) or image_ids(allow_stopped=True) != current_before_stop:
|
||||
raise ValueError("stopped services do not match the pre-backup image snapshot")
|
||||
if app_images(backup_value, "external backup images") != current_before_stop:
|
||||
raise ValueError("external backup image identities do not match the current deployment")
|
||||
repository_revision = run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True)
|
||||
if repository_revision != manifest["commit"]:
|
||||
raise ValueError("host checkout does not match the verified release commit")
|
||||
current_files = {
|
||||
name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest()
|
||||
for name in ("deploy/production/compose.yaml", "deploy/production/Caddyfile")
|
||||
@@ -195,6 +201,7 @@ def deploy_external(
|
||||
}
|
||||
journal.update({"status": "external-backup-verified", "target": target})
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
verify_release_still_current()
|
||||
replace_images(ENV_FILE, old_env, new_env)
|
||||
journal["status"] = "references-switched"
|
||||
write_json_atomic(JOURNAL_FILE, journal)
|
||||
|
||||
@@ -12,7 +12,7 @@ from archive import compose_sources
|
||||
from deployment_lock import deployment_lock
|
||||
from deploy_release import JOURNAL_FILE, ROOT, SERVICES, compose, deploy_external, image_ids
|
||||
from external_session import read_request, send_backup_session, validate_request, write_result
|
||||
from release_source import fetch_release
|
||||
from release_source import fetch_release, require_runtime_compatibility
|
||||
from release_state import read_json, validate_state, write_json_atomic
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
@@ -54,15 +54,21 @@ def execute(request: dict, *, reader, writer, policy: dict | None = None, api=No
|
||||
request, policy=policy, current_main=current_main, checkout=ROOT,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
|
||||
with deployment_lock():
|
||||
require_runtime_compatibility(
|
||||
manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE,
|
||||
**({"api": api} if api is not None else {}),
|
||||
)
|
||||
if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink():
|
||||
raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release")
|
||||
current = image_ids()
|
||||
current_record = {service: current[service] for service in SERVICES}
|
||||
revision = deploy_release_revision()
|
||||
if revision != manifest["commit"]:
|
||||
raise ValueError("installed host checkout does not match the verified release commit")
|
||||
previous_state = None
|
||||
if STATE_FILE.exists() or STATE_FILE.is_symlink():
|
||||
previous_state = validate_state(read_json(STATE_FILE))
|
||||
|
||||
@@ -1,50 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only selector for one fully verified current-main release candidate."""
|
||||
"""Read-only preparation endpoint for one fully verified current-main release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deploy_release import JOURNAL_FILE, ROOT, STATE_FILE
|
||||
from release_handoff import latest_main_sha
|
||||
from release_source import fetch_release, latest_release_request
|
||||
from release_source import fetch_release, latest_release_request, runtime_compatibility_reason
|
||||
from release_state import read_json, validate_state
|
||||
|
||||
POLICY_FILE = Path("/etc/akyldash/release-policy.json")
|
||||
STATUS_MAX_BYTES = 1024
|
||||
SHA_RE = re.compile(r"[0-9a-f]{40}\Z")
|
||||
|
||||
|
||||
def checkout_sha(checkout: Path) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(checkout), "rev-parse", "HEAD"],
|
||||
check=True, text=True, capture_output=True, timeout=10,
|
||||
)
|
||||
value = result.stdout.strip()
|
||||
if not SHA_RE.fullmatch(value):
|
||||
raise ValueError("production checkout revision is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_FILE,
|
||||
policy_file: Path = POLICY_FILE, state_file: Path = STATE_FILE,
|
||||
main_lookup=latest_main_sha,
|
||||
checkout_lookup=checkout_sha) -> dict:
|
||||
main_lookup=latest_main_sha) -> dict:
|
||||
if os.geteuid() != 0:
|
||||
raise ValueError("release status command must run as root")
|
||||
if journal.exists() or journal.is_symlink():
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None}
|
||||
|
||||
current_main = main_lookup(checkout)
|
||||
if checkout_lookup(checkout) != current_main:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "host_checkout_stale", "release": None}
|
||||
|
||||
api_args = {"api": api} if api is not None else {}
|
||||
reason = runtime_compatibility_reason(
|
||||
current_main, checkout=checkout, policy_file=policy_file, **api_args,
|
||||
)
|
||||
if reason is not None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": reason, "release": None}
|
||||
request = latest_release_request(current_main=current_main, checkout=checkout, **api_args)
|
||||
if request is None:
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "no_current_main_release", "release": None}
|
||||
@@ -70,7 +57,7 @@ def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_
|
||||
return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None}
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"state": "candidate",
|
||||
"state": "prepared",
|
||||
"reason": None,
|
||||
"release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{40}$')]
|
||||
[string]$ExpectedCommit,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BundlePath,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[0-9a-f]{64}$')]
|
||||
[string]$ExpectedBundleSha256
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$python = 'C:\Python313\python.exe'
|
||||
$git = 'C:\Program Files\Git\cmd\git.exe'
|
||||
$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'
|
||||
$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$appRoot = 'C:\Program Files\Akyldash'
|
||||
$app = Join-Path $appRoot "client-$ExpectedCommit"
|
||||
$programData = 'C:\ProgramData\Akyldash'
|
||||
$trigger = Join-Path $programData 'release-trigger.json'
|
||||
$autoApply = Join-Path $programData 'release-auto-apply.json'
|
||||
$mutationRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = [Security.Principal.WindowsPrincipal]::new($identity)
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Run this reviewed bootstrap from an elevated administrator session.'
|
||||
}
|
||||
|
||||
$signature = Get-AuthenticodeSignature -LiteralPath $python
|
||||
if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Python Software Foundation') {
|
||||
throw 'The pinned Python interpreter does not have the verified Python Software Foundation signature.'
|
||||
}
|
||||
function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" }
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
||||
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
||||
if ($AllowOwnerWrite) {
|
||||
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
} else {
|
||||
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
|
||||
}
|
||||
if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" }
|
||||
if ($AllowOwnerWrite) {
|
||||
$allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or
|
||||
($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) {
|
||||
throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path"
|
||||
}
|
||||
}
|
||||
$ownerHasFullControl = @($rules | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
$_.IdentityReference.Value -eq $identity.User.Value -and
|
||||
($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl
|
||||
}).Count -gt 0
|
||||
if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" }
|
||||
} else {
|
||||
$writableUntrusted = @($rules | Where-Object {
|
||||
$_.AccessControlType -eq 'Allow' -and
|
||||
(([int]$_.FileSystemRights -band [int]$mutationRights) -ne 0) -and
|
||||
$_.IdentityReference.Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)
|
||||
})
|
||||
if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" }
|
||||
}
|
||||
}
|
||||
function Set-TrustedCodeOwner([string]$Path) {
|
||||
$acl = Get-Acl -LiteralPath $Path
|
||||
$administratorSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$acl.SetOwner($administratorSid)
|
||||
Set-Acl -LiteralPath $Path -AclObject $acl
|
||||
}
|
||||
function Assert-TrustedPath([string]$Path, [bool]$ParentsOnly = $false) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Refusing missing, non-filesystem, or reparse protected path: $Path"
|
||||
}
|
||||
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
|
||||
$current = if ($ParentsOnly) { $item.Parent } else { $item }
|
||||
$isLeaf = -not $ParentsOnly
|
||||
while ($null -ne $current) {
|
||||
if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw "Protected path contains a reparse point: $($current.FullName)"
|
||||
}
|
||||
$acl = Get-Acl -LiteralPath $current.FullName
|
||||
$ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Protected path has an untrusted owner: $($current.FullName)"
|
||||
}
|
||||
$checkedRights = if ($isLeaf) { $mutationRights } else { $pathReplacementRights }
|
||||
$rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
|
||||
foreach ($rule in $rules) {
|
||||
$sid = $rule.IdentityReference.Value
|
||||
if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue }
|
||||
if ($rule.AccessControlType -eq 'Allow' -and
|
||||
(([int]$rule.FileSystemRights -band [int]$checkedRights) -ne 0) -and
|
||||
$sid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
|
||||
throw "Protected path grants replacement/write access outside trusted system owners: $($current.FullName)"
|
||||
}
|
||||
}
|
||||
$current = $current.Parent
|
||||
$isLeaf = $false
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }
|
||||
|
||||
Assert-ProtectedPath (Split-Path -Parent $python)
|
||||
Assert-ProtectedPath $python
|
||||
if (-not (Test-Path -LiteralPath $appRoot -PathType Container)) { throw 'Protected receiver directory is missing; provision it through the reviewed owner procedure first.' }
|
||||
Assert-TrustedPath $appRoot
|
||||
Assert-ProtectedPath $appRoot
|
||||
if (Test-Path -LiteralPath $app) { throw 'Versioned receiver destination already exists; inspect it manually instead of overwriting.' }
|
||||
if (-not (Test-Path -LiteralPath $programData -PathType Container)) { throw 'Protected ProgramData directory is missing; provision it through the reviewed owner procedure first.' }
|
||||
Assert-ProtectedPath $programData -AllowOwnerWrite $true
|
||||
Assert-TrustedPath $programData -ParentsOnly $true
|
||||
if (Test-Path -LiteralPath $trigger) { Assert-ProtectedPath $trigger -AllowOwnerWrite $true }
|
||||
if (Test-Path -LiteralPath $trigger) {
|
||||
$triggerItem = Get-Item -LiteralPath $trigger
|
||||
if ($triggerItem.Length -gt 4096) { throw 'Existing trigger configuration exceeds its size limit.' }
|
||||
$triggerConfig = Get-Content -LiteralPath $trigger -Raw | ConvertFrom-Json
|
||||
$triggerFields = @($triggerConfig.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($triggerConfig.schema_version -ne 1 -or $triggerConfig.enabled -ne $false -or
|
||||
$triggerConfig.repository -ne 'D:\Dev\akyldash' -or $triggerConfig.ssh_alias -ne 'akyldash_release' -or
|
||||
$triggerFields -ne 'enabled,repository,schema_version,ssh_alias') {
|
||||
throw 'Existing trigger configuration is not the fixed disabled configuration.'
|
||||
}
|
||||
}
|
||||
if (Test-Path -LiteralPath $autoApply) {
|
||||
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
|
||||
$autoItem = Get-Item -LiteralPath $autoApply
|
||||
if ($autoItem.Length -gt 1024) { throw 'Existing auto-apply configuration exceeds its size limit.' }
|
||||
$autoConfig = Get-Content -LiteralPath $autoApply -Raw | ConvertFrom-Json
|
||||
$autoFields = @($autoConfig.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($autoConfig.schema_version -ne 1 -or $autoConfig.enabled -ne $false -or
|
||||
$autoFields -ne 'enabled,schema_version') {
|
||||
throw 'Existing auto-apply configuration must be disabled and match its fixed schema.'
|
||||
}
|
||||
}
|
||||
|
||||
function Read-BoundedZipEntry($Entry, [long]$Maximum) {
|
||||
if ($Entry.Length -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" }
|
||||
$inputStream = $Entry.Open()
|
||||
$buffer = [IO.MemoryStream]::new()
|
||||
try {
|
||||
$chunk = New-Object byte[] 8192
|
||||
while (($count = $inputStream.Read($chunk, 0, $chunk.Length)) -gt 0) {
|
||||
if ($buffer.Length + $count -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" }
|
||||
$buffer.Write($chunk, 0, $count)
|
||||
}
|
||||
if ($buffer.Length -ne $Entry.Length) { throw "Receiver bundle entry length is invalid: $($Entry.FullName)" }
|
||||
return ,$buffer.ToArray()
|
||||
} finally {
|
||||
$inputStream.Dispose()
|
||||
$buffer.Dispose()
|
||||
}
|
||||
}
|
||||
function Assert-ReceiverBundleEntryMode($Entry) {
|
||||
$entryMode = (([long]$Entry.ExternalAttributes -shr 16) -band 0xFFFF)
|
||||
if ($entryMode -ne 0x8180) { throw "Receiver bundle entry has an unsafe file mode: $($Entry.FullName)" }
|
||||
}
|
||||
function Get-LowerHex([byte[]]$Bytes, [bool]$UseSha1 = $false) {
|
||||
$algorithm = if ($UseSha1) { [Security.Cryptography.SHA1]::Create() } else { [Security.Cryptography.SHA256]::Create() }
|
||||
try { return [BitConverter]::ToString($algorithm.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
|
||||
finally { $algorithm.Dispose() }
|
||||
}
|
||||
function Get-GitObjectHex([string]$Kind, [byte[]]$Bytes) {
|
||||
$header = [Text.Encoding]::ASCII.GetBytes("$Kind $($Bytes.Length)`0")
|
||||
$payload = New-Object byte[] ($header.Length + $Bytes.Length)
|
||||
[Array]::Copy($header, 0, $payload, 0, $header.Length)
|
||||
[Array]::Copy($Bytes, 0, $payload, $header.Length, $Bytes.Length)
|
||||
return Get-LowerHex -Bytes $payload -UseSha1 $true
|
||||
}
|
||||
function Get-GitTreeEntries([byte[]]$Bytes) {
|
||||
$entries = @{}
|
||||
$offset = 0
|
||||
$utf8Strict = [Text.UTF8Encoding]::new($false, $true)
|
||||
while ($offset -lt $Bytes.Length) {
|
||||
$space = [Array]::IndexOf($Bytes, [byte]32, $offset)
|
||||
if ($space -lt $offset) { throw 'Git tree object has a malformed mode field.' }
|
||||
$nul = [Array]::IndexOf($Bytes, [byte]0, $space + 1)
|
||||
if ($nul -lt $space -or $nul + 21 -gt $Bytes.Length) { throw 'Git tree object has a malformed entry.' }
|
||||
$mode = [Text.Encoding]::ASCII.GetString($Bytes, $offset, $space - $offset)
|
||||
$name = $utf8Strict.GetString($Bytes, $space + 1, $nul - $space - 1)
|
||||
if (-not $name -or $name -in @('.', '..') -or $entries.ContainsKey($name)) { throw 'Git tree object has an invalid or duplicate name.' }
|
||||
$objectBytes = New-Object byte[] 20
|
||||
[Array]::Copy($Bytes, $nul + 1, $objectBytes, 0, 20)
|
||||
$entries[$name] = [PSCustomObject]@{
|
||||
mode = $mode
|
||||
sha1 = [BitConverter]::ToString($objectBytes).Replace('-', '').ToLowerInvariant()
|
||||
}
|
||||
$offset = $nul + 21
|
||||
}
|
||||
return ,$entries
|
||||
}
|
||||
|
||||
# The package is opened once with write/delete sharing denied. Its bytes are
|
||||
# hashed and held in memory before any installation write; no checkout or Git
|
||||
# command participates in this elevated stage.
|
||||
$bundleItem = Get-Item -LiteralPath $BundlePath -Force
|
||||
if ($bundleItem -isnot [IO.FileInfo] -or ($bundleItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Receiver bundle must be a regular non-reparse file.'
|
||||
}
|
||||
$bundleStream = [IO.File]::Open([IO.Path]::GetFullPath($BundlePath), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
|
||||
$zip = $null
|
||||
$verifiedFiles = @{}
|
||||
try {
|
||||
if ($bundleStream.Length -le 0 -or $bundleStream.Length -gt 524288) { throw 'Receiver bundle has an invalid size.' }
|
||||
$bundleHasher = [Security.Cryptography.SHA256]::Create()
|
||||
try { $bundleHash = [BitConverter]::ToString($bundleHasher.ComputeHash($bundleStream)).Replace('-', '').ToLowerInvariant() }
|
||||
finally { $bundleHasher.Dispose() }
|
||||
if ($bundleHash -ne $ExpectedBundleSha256) { throw 'Receiver bundle SHA-256 does not match ExpectedBundleSha256.' }
|
||||
$bundleStream.Position = 0
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
$zip = [System.IO.Compression.ZipArchive]::new($bundleStream, [IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
$expectedEntries = @('manifest.json', 'objects/commit.bin', 'objects/root-tree.bin',
|
||||
'objects/infra-tree.bin', 'objects/backup-tree.bin', 'objects/deploy-tree.bin',
|
||||
'files/archive.py', 'files/external_session.py',
|
||||
'files/receive_external.py', 'files/windows-release-trigger.example.xml',
|
||||
'files/windows_release_launcher.py')
|
||||
$entryNames = @($zip.Entries | ForEach-Object { $_.FullName })
|
||||
if ($entryNames.Count -ne $expectedEntries.Count -or
|
||||
(@($entryNames | Select-Object -Unique).Count -ne $expectedEntries.Count)) {
|
||||
throw 'Receiver bundle contains duplicate or unexpected entries.'
|
||||
}
|
||||
foreach ($expectedEntry in $expectedEntries) {
|
||||
if ($entryNames -cnotcontains $expectedEntry) { throw "Receiver bundle is missing an exact required entry: $expectedEntry" }
|
||||
}
|
||||
foreach ($zipEntry in $zip.Entries) {
|
||||
Assert-ReceiverBundleEntryMode -Entry $zipEntry
|
||||
}
|
||||
$manifestBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('manifest.json') -Maximum 16384
|
||||
$utf8Strict = [Text.UTF8Encoding]::new($false, $true)
|
||||
$manifest = $utf8Strict.GetString($manifestBytes) | ConvertFrom-Json
|
||||
$manifestFields = @($manifest.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($manifestFields -ne 'commit,files,schema_version,trees' -or
|
||||
($manifest.schema_version -isnot [int] -and $manifest.schema_version -isnot [long]) -or
|
||||
$manifest.schema_version -ne 1 -or $manifest.commit -isnot [string] -or
|
||||
$manifest.commit -cne $ExpectedCommit -or $manifest.files -isnot [Array] -or
|
||||
@($manifest.files).Count -ne 5 -or $manifest.trees -isnot [Array] -or @($manifest.trees).Count -ne 4) {
|
||||
throw 'Receiver bundle manifest does not match the expected commit and schema.'
|
||||
}
|
||||
$commitBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('objects/commit.bin') -Maximum 16384
|
||||
if ((Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes) -cne $ExpectedCommit) {
|
||||
throw 'Receiver bundle Git commit object does not match ExpectedCommit.'
|
||||
}
|
||||
$commitText = [Text.Encoding]::ASCII.GetString($commitBytes)
|
||||
$headerEnd = $commitText.IndexOf("`n`n", [StringComparison]::Ordinal)
|
||||
if ($headerEnd -lt 1) { throw 'Receiver bundle commit header is malformed.' }
|
||||
$treeHeaders = [regex]::Matches($commitText.Substring(0, $headerEnd), '(?m)^tree ([0-9a-f]{40})$')
|
||||
if ($treeHeaders.Count -ne 1) { throw 'Receiver bundle commit has no unique root tree reference.' }
|
||||
|
||||
$treeEntries = @{}
|
||||
$treeObjectNames = @{
|
||||
'' = 'objects/root-tree.bin'
|
||||
infra = 'objects/infra-tree.bin'
|
||||
'infra/backup' = 'objects/backup-tree.bin'
|
||||
'infra/deploy' = 'objects/deploy-tree.bin'
|
||||
}
|
||||
$seenTrees = @{}
|
||||
foreach ($tree in $manifest.trees) {
|
||||
$treeFields = @($tree.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($treeFields -ne 'path,sha1' -or $tree.path -isnot [string] -or
|
||||
$tree.path -cnotin @('', 'infra', 'infra/backup', 'infra/deploy') -or
|
||||
$seenTrees.ContainsKey($tree.path) -or $tree.sha1 -cnotmatch '^[0-9a-f]{40}$') {
|
||||
throw 'Receiver bundle tree manifest is invalid.'
|
||||
}
|
||||
$treeData = Read-BoundedZipEntry -Entry $zip.GetEntry($treeObjectNames[$tree.path]) -Maximum 262144
|
||||
if ((Get-GitObjectHex -Kind 'tree' -Bytes $treeData) -cne $tree.sha1) {
|
||||
throw "Receiver bundle tree object identity does not match its manifest: $($tree.path)"
|
||||
}
|
||||
$treeEntries[$tree.path] = Get-GitTreeEntries -Bytes $treeData
|
||||
$seenTrees[$tree.path] = $tree.sha1
|
||||
}
|
||||
if ($seenTrees.Count -ne 4 -or $treeHeaders[0].Groups[1].Value -cne $seenTrees['']) {
|
||||
throw 'Receiver bundle commit does not bind the declared root tree.'
|
||||
}
|
||||
$rootInfra = $treeEntries['']['infra']
|
||||
$infraBackup = $treeEntries['infra']['backup']
|
||||
$infraDeploy = $treeEntries['infra']['deploy']
|
||||
if ($rootInfra.mode -notin @('40000', '040000') -or $rootInfra.sha1 -cne $seenTrees['infra'] -or
|
||||
$infraBackup.mode -notin @('40000', '040000') -or $infraBackup.sha1 -cne $seenTrees['infra/backup'] -or
|
||||
$infraDeploy.mode -notin @('40000', '040000') -or $infraDeploy.sha1 -cne $seenTrees['infra/deploy']) {
|
||||
throw 'Receiver bundle directory tree chain does not match the commit.'
|
||||
}
|
||||
$allowedFiles = @('archive.py', 'external_session.py', 'receive_external.py',
|
||||
'windows-release-trigger.example.xml', 'windows_release_launcher.py')
|
||||
$seenFiles = @{}
|
||||
foreach ($file in $manifest.files) {
|
||||
$fileFields = @($file.PSObject.Properties.Name | Sort-Object) -join ','
|
||||
if ($fileFields -ne 'git_blob_sha1,path,sha256,size' -or $file.path -isnot [string] -or
|
||||
$file.path -cnotin $allowedFiles -or $seenFiles.ContainsKey($file.path) -or
|
||||
$file.git_blob_sha1 -isnot [string] -or $file.git_blob_sha1 -cnotmatch '^[0-9a-f]{40}$' -or
|
||||
$file.sha256 -isnot [string] -or $file.sha256 -cnotmatch '^[0-9a-f]{64}$' -or
|
||||
($file.size -isnot [int] -and $file.size -isnot [long]) -or $file.size -lt 0 -or $file.size -gt 262144) {
|
||||
throw 'Receiver bundle manifest contains an invalid file record.'
|
||||
}
|
||||
$entry = $zip.GetEntry("files/$($file.path)")
|
||||
$data = Read-BoundedZipEntry -Entry $entry -Maximum 262144
|
||||
if ($data.Length -ne [long]$file.size -or
|
||||
(Get-LowerHex -Bytes $data) -cne $file.sha256) {
|
||||
throw "Receiver bundle file digest or size does not match its manifest: $($file.path)"
|
||||
}
|
||||
if ((Get-GitObjectHex -Kind 'blob' -Bytes $data) -cne $file.git_blob_sha1) {
|
||||
throw "Receiver bundle Git blob identity does not match its manifest: $($file.path)"
|
||||
}
|
||||
$treePath = if ($file.path -in @('archive.py', 'external_session.py', 'receive_external.py')) { 'infra/backup' } else { 'infra/deploy' }
|
||||
$treeFile = $treeEntries[$treePath][$file.path]
|
||||
if ($null -eq $treeFile -or $treeFile.mode -ne '100644' -or $treeFile.sha1 -cne $file.git_blob_sha1) {
|
||||
throw "Receiver bundle commit tree does not contain the exact source file: $($file.path)"
|
||||
}
|
||||
$seenFiles[$file.path] = $true
|
||||
$verifiedFiles[$file.path] = $data
|
||||
}
|
||||
if ($seenFiles.Count -ne $allowedFiles.Count) { throw 'Receiver bundle manifest omitted a required file.' }
|
||||
} finally {
|
||||
if ($null -ne $zip) { $zip.Dispose() }
|
||||
$bundleStream.Dispose()
|
||||
}
|
||||
|
||||
$appSecurity = Get-Acl -LiteralPath $appRoot
|
||||
$appSecurity.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))
|
||||
$null = [IO.Directory]::CreateDirectory($app, $appSecurity)
|
||||
Set-TrustedCodeOwner $app
|
||||
Assert-ProtectedPath $app
|
||||
foreach ($name in @('receive_external.py', 'archive.py', 'external_session.py', 'windows_release_launcher.py')) {
|
||||
$destination = Join-Path $app $name
|
||||
[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])
|
||||
Set-TrustedCodeOwner $destination
|
||||
Assert-ProtectedPath $destination
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $trigger)) {
|
||||
$config = '{"schema_version":1,"enabled":false,"repository":"D:\\Dev\\akyldash","ssh_alias":"akyldash_release"}'
|
||||
Set-Content -LiteralPath $trigger -Value $config -Encoding Ascii -NoNewline
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $autoApply)) {
|
||||
Set-Content -LiteralPath $autoApply -Value '{"schema_version":1,"enabled":false}' -Encoding Ascii -NoNewline
|
||||
}
|
||||
Assert-ProtectedPath $trigger -AllowOwnerWrite $true
|
||||
Assert-ProtectedPath $autoApply -AllowOwnerWrite $true
|
||||
|
||||
$template = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml'])
|
||||
$ns = [System.Xml.XmlNamespaceManager]::new($template.NameTable)
|
||||
$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$sid = $identity.User.Value
|
||||
$template.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $ns).InnerText = $sid
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $ns).InnerText = $python
|
||||
$launcher = Join-Path $app 'windows_release_launcher.py'
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $ns).InnerText = "-B -E -s -S `"$launcher`" --once"
|
||||
$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $ns).InnerText = $app
|
||||
$template.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $ns).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$templatePath = Join-Path $programData 'Akyldash-ReleaseTrigger-disabled.xml'
|
||||
$template.Save($templatePath)
|
||||
$applyTemplate = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml'])
|
||||
$applyNs = [System.Xml.XmlNamespaceManager]::new($applyTemplate.NameTable)
|
||||
$applyNs.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task')
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI', $applyNs).InnerText = '\Akyldash\ReleaseAutoApply'
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $applyNs).InnerText = $sid
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $applyNs).InnerText = $python
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $applyNs).InnerText = "-B -E -s -S `"$launcher`" --auto-apply-prepared"
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $applyNs).InnerText = $app
|
||||
$applyTemplate.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $applyNs).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
$applyTemplate.Save((Join-Path $programData 'Akyldash-ReleaseAutoApply-disabled.xml'))
|
||||
Write-Output "Prepared disabled receiver files from verified source commit $ExpectedCommit."
|
||||
Write-Output 'No task was registered or enabled; both configuration gates remain disabled.'
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Build a small immutable Windows receiver bundle from one Git commit."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
FILES = {
|
||||
"archive.py": "infra/backup/archive.py",
|
||||
"external_session.py": "infra/backup/external_session.py",
|
||||
"receive_external.py": "infra/backup/receive_external.py",
|
||||
"windows-release-trigger.example.xml": "infra/deploy/windows-release-trigger.example.xml",
|
||||
"windows_release_launcher.py": "infra/deploy/windows_release_launcher.py",
|
||||
}
|
||||
MAX_BUNDLE_BYTES = 512 * 1024
|
||||
MAX_FILE_BYTES = 262144
|
||||
TREE_OBJECTS = {
|
||||
"": "objects/root-tree.bin",
|
||||
"infra": "objects/infra-tree.bin",
|
||||
"infra/backup": "objects/backup-tree.bin",
|
||||
"infra/deploy": "objects/deploy-tree.bin",
|
||||
}
|
||||
|
||||
|
||||
def _git(repository: Path, *arguments: str) -> bytes:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repository), *arguments],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _blob_sha(data: bytes) -> str:
|
||||
return _git_object_sha("blob", data)
|
||||
|
||||
|
||||
def _git_object_sha(kind: str, data: bytes) -> str:
|
||||
return hashlib.sha1(kind.encode("ascii") + b" " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest()
|
||||
|
||||
|
||||
def _tree_entry(repository: Path, tree: str, name: str) -> tuple[str, str, str]:
|
||||
records = _git(repository, "ls-tree", "-z", tree, "--", name).split(b"\0")
|
||||
matches = []
|
||||
for record in records:
|
||||
if not record:
|
||||
continue
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, object_id = header.decode("ascii").split(" ")
|
||||
if raw_path.decode("utf-8") == name:
|
||||
matches.append((mode, kind, object_id))
|
||||
if len(matches) != 1:
|
||||
raise ValueError(f"Git tree is missing or duplicates required path: {name}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _parse_git_tree(data: bytes) -> dict[str, tuple[str, str]]:
|
||||
entries: dict[str, tuple[str, str]] = {}
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
space = data.find(b" ", offset)
|
||||
nul = data.find(b"\0", space + 1) if space >= 0 else -1
|
||||
if space < offset or nul < 0 or nul + 21 > len(data):
|
||||
raise ValueError("Git tree object is malformed")
|
||||
mode = data[offset:space].decode("ascii")
|
||||
name = data[space + 1:nul].decode("utf-8")
|
||||
object_id = data[nul + 1:nul + 21].hex()
|
||||
if not name or name in {".", ".."} or name in entries:
|
||||
raise ValueError("Git tree object has an invalid or duplicate name")
|
||||
entries[name] = (mode, object_id)
|
||||
offset = nul + 21
|
||||
return entries
|
||||
|
||||
|
||||
def _strict_json_object(pairs):
|
||||
value = {}
|
||||
for key, item in pairs:
|
||||
if key in value:
|
||||
raise ValueError("receiver bundle manifest has duplicate keys")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
|
||||
def verify_bundle(payload: bytes, expected_commit: str, expected_sha256: str) -> dict[str, bytes]:
|
||||
"""Verify a generated or transferred bundle before an installer consumes it."""
|
||||
if len(payload) <= 0 or len(payload) > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("receiver bundle has an invalid size")
|
||||
if hashlib.sha256(payload).hexdigest() != expected_sha256:
|
||||
raise ValueError("receiver bundle SHA-256 does not match")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", expected_commit):
|
||||
raise ValueError("expected commit must be a full lowercase Git SHA-1")
|
||||
expected_entries = {
|
||||
"manifest.json", "objects/commit.bin", *TREE_OBJECTS.values(),
|
||||
*(f"files/{name}" for name in FILES),
|
||||
}
|
||||
try:
|
||||
archive = zipfile.ZipFile(io.BytesIO(payload))
|
||||
except (zipfile.BadZipFile, OSError) as error:
|
||||
raise ValueError("receiver bundle is not a valid ZIP") from error
|
||||
with archive:
|
||||
infos = archive.infolist()
|
||||
names = [item.filename for item in infos]
|
||||
if len(names) != len(expected_entries) or len(set(names)) != len(expected_entries) or set(names) != expected_entries:
|
||||
raise ValueError("receiver bundle has duplicate, missing, or unexpected entries")
|
||||
for item in infos:
|
||||
mode = (item.external_attr >> 16) & 0xFFFF
|
||||
if mode != 0o100600 or item.compress_type != zipfile.ZIP_STORED:
|
||||
raise ValueError("receiver bundle entry has an unsafe mode or compression method")
|
||||
maximum = 16384 if item.filename == "manifest.json" else MAX_FILE_BYTES
|
||||
if item.file_size > maximum:
|
||||
raise ValueError("receiver bundle entry exceeds its size limit")
|
||||
contents = {item.filename: archive.read(item) for item in infos}
|
||||
|
||||
try:
|
||||
manifest = json.loads(contents["manifest.json"].decode("ascii"), object_pairs_hook=_strict_json_object)
|
||||
except (UnicodeError, json.JSONDecodeError) as error:
|
||||
raise ValueError("receiver bundle manifest is invalid") from error
|
||||
if (not isinstance(manifest, dict) or set(manifest) != {"schema_version", "commit", "files", "trees"}
|
||||
or type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1
|
||||
or manifest["commit"] != expected_commit):
|
||||
raise ValueError("receiver bundle manifest does not match the expected commit and schema")
|
||||
|
||||
commit_object = contents["objects/commit.bin"]
|
||||
if _git_object_sha("commit", commit_object) != expected_commit:
|
||||
raise ValueError("receiver bundle commit object does not match expected SHA")
|
||||
commit_header = commit_object.split(b"\n\n", 1)[0]
|
||||
commit_trees = re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)
|
||||
if len(commit_trees) != 1:
|
||||
raise ValueError("receiver bundle commit has no unique root tree")
|
||||
|
||||
if not isinstance(manifest["trees"], list) or len(manifest["trees"]) != len(TREE_OBJECTS):
|
||||
raise ValueError("receiver bundle tree manifest is invalid")
|
||||
tree_hashes: dict[str, str] = {}
|
||||
tree_entries: dict[str, dict[str, tuple[str, str]]] = {}
|
||||
for record in manifest["trees"]:
|
||||
if (not isinstance(record, dict) or set(record) != {"path", "sha1"}
|
||||
or not isinstance(record["path"], str) or record["path"] not in TREE_OBJECTS
|
||||
or record["path"] in tree_hashes
|
||||
or not isinstance(record["sha1"], str) or not re.fullmatch(r"[0-9a-f]{40}", record["sha1"])):
|
||||
raise ValueError("receiver bundle tree record is invalid")
|
||||
path = record["path"]
|
||||
data = contents[TREE_OBJECTS[path]]
|
||||
if _git_object_sha("tree", data) != record["sha1"]:
|
||||
raise ValueError(f"receiver bundle tree hash does not match: {path}")
|
||||
tree_hashes[path] = record["sha1"]
|
||||
tree_entries[path] = _parse_git_tree(data)
|
||||
if set(tree_hashes) != set(TREE_OBJECTS) or tree_hashes[""].encode("ascii") != commit_trees[0]:
|
||||
raise ValueError("receiver bundle commit does not bind the declared root tree")
|
||||
for parent, name, child in (("", "infra", "infra"),
|
||||
("infra", "backup", "infra/backup"),
|
||||
("infra", "deploy", "infra/deploy")):
|
||||
mode, child_sha = tree_entries[parent].get(name, ("", ""))
|
||||
if mode not in {"40000", "040000"} or child_sha != tree_hashes[child]:
|
||||
raise ValueError(f"receiver bundle directory tree chain is invalid at {parent or '/'}/{name}")
|
||||
|
||||
if not isinstance(manifest["files"], list) or len(manifest["files"]) != len(FILES):
|
||||
raise ValueError("receiver bundle file manifest is invalid")
|
||||
verified: dict[str, bytes] = {}
|
||||
for record in manifest["files"]:
|
||||
if (not isinstance(record, dict) or set(record) != {"path", "git_blob_sha1", "sha256", "size"}
|
||||
or not isinstance(record["path"], str) or record["path"] not in FILES or record["path"] in verified
|
||||
or type(record["size"]) is not int or record["size"] < 0 or record["size"] > MAX_FILE_BYTES
|
||||
or not isinstance(record["git_blob_sha1"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", record["git_blob_sha1"])
|
||||
or not isinstance(record["sha256"], str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", record["sha256"])):
|
||||
raise ValueError("receiver bundle file record is invalid")
|
||||
name = record["path"]
|
||||
data = contents[f"files/{name}"]
|
||||
tree_path = "infra/backup" if FILES[name].startswith("infra/backup/") else "infra/deploy"
|
||||
mode, blob_sha = tree_entries[tree_path].get(name, ("", ""))
|
||||
if (mode != "100644" or blob_sha != record["git_blob_sha1"]
|
||||
or _git_object_sha("blob", data) != blob_sha
|
||||
or hashlib.sha256(data).hexdigest() != record["sha256"]
|
||||
or len(data) != record["size"]):
|
||||
raise ValueError(f"receiver bundle file does not match its commit tree: {name}")
|
||||
verified[name] = data
|
||||
if set(verified) != set(FILES):
|
||||
raise ValueError("receiver bundle omits a required source file")
|
||||
return verified
|
||||
|
||||
|
||||
def read_commit_files(repository: Path, commit: str) -> tuple[
|
||||
dict[str, bytes], list[dict[str, object]], dict[str, bytes], list[dict[str, str]]]:
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
raise ValueError("expected commit must be a full lowercase Git SHA-1")
|
||||
if repository.is_symlink():
|
||||
raise ValueError("repository must be a real directory")
|
||||
repository = repository.resolve(strict=True)
|
||||
if not repository.is_dir():
|
||||
raise ValueError("repository must be a real directory")
|
||||
if _git(repository, "cat-file", "-t", commit).strip() != b"commit":
|
||||
raise ValueError("expected object is not a commit")
|
||||
main_commit = _git(repository, "rev-parse", "refs/heads/main").strip().decode("ascii")
|
||||
if main_commit != commit:
|
||||
raise ValueError("expected commit must equal the local main branch tip")
|
||||
commit_object = _git(repository, "cat-file", "commit", commit)
|
||||
if _git_object_sha("commit", commit_object) != commit:
|
||||
raise ValueError("commit object bytes do not match the expected SHA")
|
||||
commit_header = commit_object.split(b"\n\n", 1)[0]
|
||||
commit_trees = [value.decode("ascii") for value in
|
||||
re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)]
|
||||
if len(commit_trees) != 1:
|
||||
raise ValueError("commit object has no unique root tree")
|
||||
tree_ids = {"": commit_trees[0]}
|
||||
tree_ids["infra"] = _tree_entry(repository, tree_ids[""], "infra")[2]
|
||||
tree_ids["infra/backup"] = _tree_entry(repository, tree_ids["infra"], "backup")[2]
|
||||
tree_ids["infra/deploy"] = _tree_entry(repository, tree_ids["infra"], "deploy")[2]
|
||||
tree_objects: dict[str, bytes] = {"objects/commit.bin": commit_object}
|
||||
manifest_trees: list[dict[str, str]] = []
|
||||
for tree_path, tree_sha in sorted(tree_ids.items()):
|
||||
data = _git(repository, "cat-file", "tree", tree_sha)
|
||||
if _git_object_sha("tree", data) != tree_sha:
|
||||
raise ValueError(f"Git tree bytes do not match their object ID: {tree_path or '/'}")
|
||||
tree_objects[TREE_OBJECTS[tree_path]] = data
|
||||
manifest_trees.append({"path": tree_path, "sha1": tree_sha})
|
||||
records = _git(repository, "ls-tree", "-z", commit, "--", *FILES.values()).split(b"\0")
|
||||
tree_entries: dict[str, tuple[str, str, str]] = {}
|
||||
for record in records:
|
||||
if not record:
|
||||
continue
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, object_id = header.decode("ascii").split(" ")
|
||||
tree_entries[raw_path.decode("utf-8")] = (mode, kind, object_id)
|
||||
if set(tree_entries) != set(FILES.values()):
|
||||
raise ValueError("commit does not contain the exact receiver allowlist")
|
||||
|
||||
contents: dict[str, bytes] = {}
|
||||
manifest_files: list[dict[str, object]] = []
|
||||
for name, source_path in sorted(FILES.items()):
|
||||
mode, kind, object_id = tree_entries[source_path]
|
||||
if mode != "100644" or kind != "blob":
|
||||
raise ValueError(f"receiver source is not a regular Git blob: {source_path}")
|
||||
data = _git(repository, "cat-file", "blob", object_id)
|
||||
if _blob_sha(data) != object_id:
|
||||
raise ValueError(f"Git blob bytes do not match the commit object: {source_path}")
|
||||
contents[name] = data
|
||||
manifest_files.append({
|
||||
"path": name,
|
||||
"git_blob_sha1": object_id,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data),
|
||||
})
|
||||
for name, source_path in FILES.items():
|
||||
parent_path = "infra/backup" if source_path.startswith("infra/backup/") else "infra/deploy"
|
||||
_mode, _kind, expected_blob = tree_entries[source_path]
|
||||
mode, kind, tree_blob = _tree_entry(repository, tree_ids[parent_path], name)
|
||||
if mode != "100644" or kind != "blob" or tree_blob != expected_blob:
|
||||
raise ValueError(f"Git tree path does not bind the expected regular source blob: {source_path}")
|
||||
return contents, manifest_files, tree_objects, manifest_trees
|
||||
|
||||
|
||||
def create_bundle(repository: Path, commit: str) -> bytes:
|
||||
contents, manifest_files, tree_objects, manifest_trees = read_commit_files(repository, commit)
|
||||
manifest = json.dumps({
|
||||
"schema_version": 1,
|
||||
"commit": commit,
|
||||
"files": manifest_files,
|
||||
"trees": manifest_trees,
|
||||
}, sort_keys=True, separators=(",", ":")).encode("ascii") + b"\n"
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as bundle:
|
||||
members = [("manifest.json", manifest)]
|
||||
members.extend((f"files/{key}", value) for key, value in sorted(contents.items()))
|
||||
members.extend(sorted(tree_objects.items()))
|
||||
for name, data in members:
|
||||
entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
|
||||
entry.compress_type = zipfile.ZIP_STORED
|
||||
entry.external_attr = 0o100600 << 16
|
||||
bundle.writestr(entry, data)
|
||||
result = output.getvalue()
|
||||
if len(result) > MAX_BUNDLE_BYTES:
|
||||
raise ValueError("receiver bundle exceeds its size limit")
|
||||
verify_bundle(result, commit, hashlib.sha256(result).hexdigest())
|
||||
return result
|
||||
|
||||
|
||||
def write_bundle(repository: Path, commit: str, output_path: Path) -> str:
|
||||
if output_path.exists() or output_path.is_symlink():
|
||||
raise ValueError("refusing to overwrite an existing release bundle")
|
||||
if not output_path.parent.is_dir() or output_path.parent.is_symlink():
|
||||
raise ValueError("release bundle output directory must already exist and be a real directory")
|
||||
payload = create_bundle(repository, commit)
|
||||
with output_path.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, required=True)
|
||||
parser.add_argument("--commit", required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
digest = write_bundle(args.repository, args.commit, args.output)
|
||||
print(f"Prepared receiver bundle for {args.commit}")
|
||||
print(f"Bundle SHA-256: {digest}")
|
||||
print(f"Bundle path: {args.output.resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
@@ -26,6 +27,12 @@ MAX_API_RESPONSE_BYTES = 1024 * 1024
|
||||
MAX_API_DOWNLOAD_BYTES = 10 * 1024 * 1024
|
||||
MAX_CANDIDATE_RELEASE_RUNS = 10
|
||||
API_TIMEOUT_SECONDS = 15
|
||||
RUNTIME_FILES = (
|
||||
"deploy/production/compose.yaml",
|
||||
"deploy/production/Caddyfile",
|
||||
"infra/deploy/release-policy.json",
|
||||
)
|
||||
MAX_RUNTIME_FILE_BYTES = 256 * 1024
|
||||
|
||||
|
||||
def run_api_helper(command: list[str], *, env: dict | None, timeout: int,
|
||||
@@ -85,6 +92,58 @@ def api_json(path: str, *, helper: str = "gitea-api", env: dict | None = None) -
|
||||
return value
|
||||
|
||||
|
||||
def repository_file(path: str, commit: str, *, api=api_json) -> bytes:
|
||||
"""Fetch one small, pinned repository file and verify Gitea's git blob identity."""
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit) or path not in RUNTIME_FILES:
|
||||
raise ValueError("runtime file request is not allowlisted or commit-pinned")
|
||||
query = urlencode({"ref": commit})
|
||||
value = api(f"/repos/{REPOSITORY}/contents/{path}?{query}")
|
||||
if (value.get("type") != "file" or value.get("path") != path
|
||||
or value.get("encoding") != "base64"):
|
||||
raise ValueError("Gitea runtime file metadata is invalid")
|
||||
encoded, blob_sha, size = value.get("content"), value.get("sha"), value.get("size")
|
||||
if (not isinstance(encoded, str) or not isinstance(blob_sha, str)
|
||||
or not re.fullmatch(r"[0-9a-f]{40}", blob_sha)
|
||||
or isinstance(size, bool) or not isinstance(size, int)
|
||||
or size < 0 or size > MAX_RUNTIME_FILE_BYTES):
|
||||
raise ValueError("Gitea runtime file exceeds its limit or has invalid metadata")
|
||||
try:
|
||||
content = base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, base64.binascii.Error) as error:
|
||||
raise ValueError("Gitea runtime file content is invalid base64") from error
|
||||
git_blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode("ascii") + b"\0" + content).hexdigest()
|
||||
if len(content) != size or git_blob_sha != blob_sha:
|
||||
raise ValueError("Gitea runtime file content does not match its pinned git blob")
|
||||
return content
|
||||
|
||||
|
||||
def runtime_compatibility_reason(commit: str, *, checkout: Path, policy_file: Path,
|
||||
api=api_json) -> str | None:
|
||||
"""Require the bootstrap host's fixed runtime/configuration to match target source."""
|
||||
for relative in RUNTIME_FILES:
|
||||
local_path = policy_file if relative == "infra/deploy/release-policy.json" else checkout / relative
|
||||
try:
|
||||
info = local_path.lstat()
|
||||
if not stat.S_ISREG(info.st_mode):
|
||||
raise ValueError(f"protected runtime file is not a regular file: {relative}")
|
||||
local_content = local_path.read_bytes()
|
||||
except OSError as error:
|
||||
raise ValueError(f"protected runtime file is unavailable: {relative}") from error
|
||||
remote_content = repository_file(relative, commit, api=api)
|
||||
if local_content != remote_content:
|
||||
return "release_policy_changed" if relative.endswith("release-policy.json") else "runtime_config_changed"
|
||||
return None
|
||||
|
||||
|
||||
def require_runtime_compatibility(commit: str, *, checkout: Path, policy_file: Path,
|
||||
api=api_json) -> None:
|
||||
reason = runtime_compatibility_reason(
|
||||
commit, checkout=checkout, policy_file=policy_file, api=api,
|
||||
)
|
||||
if reason is not None:
|
||||
raise ValueError(f"host {reason.replace('_', ' ')}; reviewed bootstrap update is required")
|
||||
|
||||
|
||||
def latest_release_request(*, current_main: str, api=api_json,
|
||||
checkout: Path | None = None) -> dict | None:
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", current_main):
|
||||
|
||||
@@ -87,6 +87,59 @@ class DeploymentCutoverTests(unittest.TestCase):
|
||||
run_mock.assert_not_called()
|
||||
compose_mock.assert_not_called()
|
||||
|
||||
def test_main_advance_after_backup_aborts_before_image_reference_switch(self):
|
||||
commit = "b" * 40
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "deploy/production").mkdir(parents=True)
|
||||
for name, payload in (("compose.yaml", b"compose\n"), ("Caddyfile", b"caddy\n")):
|
||||
(root / "deploy/production" / name).write_bytes(payload)
|
||||
from hashlib import sha256
|
||||
deployment_files = {
|
||||
f"deploy/production/{name}": sha256((root / "deploy/production" / name).read_bytes()).hexdigest()
|
||||
for name in ("compose.yaml", "Caddyfile")
|
||||
}
|
||||
backup = {"images": self.current, "deployment_files": deployment_files}
|
||||
backup_identity = {
|
||||
"backup_id": "20261103T000000Z-" + "a" * 32,
|
||||
"size": 100, "archive_sha256": "c" * 64,
|
||||
"manifest_sha256": "d" * 64, "manifest": backup,
|
||||
}
|
||||
manifest = {
|
||||
"commit": commit,
|
||||
"builder": {"name": "Kaniko", "image": deploy_release.BUILDER_IMAGE},
|
||||
"images": {service: f"registry/{service}@sha256:{'e' * 64}" for service in ("backend", "opensearch")},
|
||||
}
|
||||
journal = {"status": "external-backup-verified"}
|
||||
with (
|
||||
patch.object(deploy_release.os, "geteuid", return_value=0),
|
||||
patch.object(deploy_release, "ROOT", root),
|
||||
patch.object(deploy_release, "STATE_FILE", root / "state.json"),
|
||||
patch.object(deploy_release, "JOURNAL_FILE", root / "journal.json"),
|
||||
patch.object(deploy_release, "validate_manifest", return_value=manifest),
|
||||
patch("release_state.validate_backup_identity", return_value=backup_identity),
|
||||
patch("release_handoff.latest_main_sha", side_effect=(commit, "f" * 40)),
|
||||
patch("release_source.require_runtime_compatibility"),
|
||||
patch.object(deploy_release, "image_ids", return_value=self.current),
|
||||
patch.object(deploy_release, "app_images", return_value=self.current),
|
||||
patch.object(deploy_release, "read_json", return_value=journal),
|
||||
patch.object(deploy_release, "write_json_atomic"),
|
||||
patch.object(deploy_release, "run", side_effect=(
|
||||
"", "sha256:" + "1" * 64, "", "sha256:" + "2" * 64,
|
||||
"sha256:" + "1" * 64, "sha256:" + "2" * 64,
|
||||
)),
|
||||
patch.object(deploy_release, "replace_images") as replace,
|
||||
patch.object(deploy_release, "restore_previous_references"),
|
||||
patch.object(deploy_release, "compose"),
|
||||
patch.object(deploy_release, "write_deployed_backend_image"),
|
||||
):
|
||||
with self.assertRaisesRegex(ValueError, "cutover failed"):
|
||||
deploy_release.deploy_external(
|
||||
manifest, {}, backup, backup_identity, "9" * 64,
|
||||
self.current, "ru", "ky",
|
||||
)
|
||||
replace.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -28,7 +28,7 @@ def release_state(commit: str, descriptor: str) -> dict:
|
||||
|
||||
|
||||
class ExternalDeployTests(unittest.TestCase):
|
||||
def run_execute(self, root: Path, state: dict, *, compose_mock=None):
|
||||
def run_execute(self, root: Path, state: dict, *, compose_mock=None, compatibility_error=None):
|
||||
compose_mock = compose_mock or Mock()
|
||||
state_file = root / "state.json"
|
||||
state_file.write_text("{}", encoding="utf-8")
|
||||
@@ -43,6 +43,7 @@ class ExternalDeployTests(unittest.TestCase):
|
||||
patch.object(external_deploy, "JOURNAL_FILE", journal),
|
||||
patch.object(external_deploy, "STATE_FILE", state_file),
|
||||
patch.object(external_deploy, "fetch_release", return_value=({"commit": COMMIT}, b"")),
|
||||
patch.object(external_deploy, "require_runtime_compatibility", side_effect=compatibility_error),
|
||||
patch.object(external_deploy, "deployment_lock", return_value=nullcontext()),
|
||||
patch.object(external_deploy, "image_ids", return_value=CURRENT),
|
||||
patch.object(external_deploy, "deploy_release_revision", return_value=COMMIT),
|
||||
@@ -73,6 +74,30 @@ class ExternalDeployTests(unittest.TestCase):
|
||||
self.assertEqual(journal["status"], "external-backup-pending")
|
||||
self.assertEqual(journal["commit"], COMMIT)
|
||||
|
||||
def test_runtime_mismatch_fails_before_journal_or_production_operations(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "runtime config mismatch"):
|
||||
self.run_execute(
|
||||
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
|
||||
compatibility_error=ValueError("runtime config mismatch"),
|
||||
)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
def test_runtime_change_after_initial_check_fails_inside_lock_before_stop(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
compose = Mock()
|
||||
with self.assertRaisesRegex(ValueError, "changed after status"):
|
||||
self.run_execute(
|
||||
root, release_state("b" * 40, "e" * 64), compose_mock=compose,
|
||||
compatibility_error=[None, ValueError("runtime changed after status")],
|
||||
)
|
||||
self.assertFalse((root / "journal.json").exists())
|
||||
compose.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,7 +8,7 @@ import external_status
|
||||
|
||||
|
||||
class ExternalStatusTests(unittest.TestCase):
|
||||
def test_status_is_candidate_only_after_release_descriptor_verification(self):
|
||||
def test_status_is_prepared_only_after_release_descriptor_verification(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -19,15 +19,19 @@ class ExternalStatusTests(unittest.TestCase):
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "latest_main_sha", return_value=commit), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None) as compatible, \
|
||||
patch.object(external_status, "latest_release_request", return_value=request) as select, \
|
||||
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
|
||||
value = external_status.status_document(
|
||||
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
|
||||
state_file=state, main_lookup=lambda _path: commit,
|
||||
checkout_lookup=lambda _path: commit,
|
||||
)
|
||||
self.assertEqual(value["state"], "candidate")
|
||||
self.assertEqual(value["state"], "prepared")
|
||||
self.assertEqual(value["release"], request)
|
||||
compatible.assert_called_once()
|
||||
self.assertEqual(compatible.call_args.args, (commit,))
|
||||
self.assertEqual(compatible.call_args.kwargs["checkout"], root)
|
||||
self.assertEqual(compatible.call_args.kwargs["policy_file"], policy)
|
||||
select.assert_called_once_with(current_main=commit, checkout=root, api=ANY)
|
||||
verify.assert_called_once()
|
||||
|
||||
@@ -46,17 +50,38 @@ class ExternalStatusTests(unittest.TestCase):
|
||||
"current": images, "previous": images,
|
||||
}), encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
|
||||
patch.object(external_status, "latest_release_request", return_value=request), \
|
||||
patch.object(external_status, "fetch_release") as verify:
|
||||
result = external_status.status_document(
|
||||
api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy,
|
||||
state_file=state, main_lookup=lambda _path: commit,
|
||||
checkout_lookup=lambda _path: commit,
|
||||
)
|
||||
self.assertEqual(result["reason"], "already_applied")
|
||||
verify.assert_not_called()
|
||||
|
||||
def test_status_fails_closed_for_root_guard_recovery_and_stale_checkout(self):
|
||||
def test_status_does_not_publish_prepared_tuple_if_main_advances_during_verification(self):
|
||||
commit = "a" * 40
|
||||
request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
policy = root / "policy.json"
|
||||
policy.write_text("{}", encoding="utf-8")
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value=None), \
|
||||
patch.object(external_status, "latest_release_request", return_value=request), \
|
||||
patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify:
|
||||
mains = iter((commit, "c" * 40))
|
||||
result = external_status.status_document(
|
||||
checkout=root, journal=root / "journal", policy_file=policy,
|
||||
state_file=root / "state.json", main_lookup=lambda _path: next(mains),
|
||||
)
|
||||
self.assertEqual(result, {
|
||||
"schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None,
|
||||
})
|
||||
verify.assert_called_once()
|
||||
|
||||
def test_status_fails_closed_for_root_guard_recovery_and_runtime_mismatch(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
journal = root / "recovery.json"
|
||||
@@ -70,11 +95,15 @@ class ExternalStatusTests(unittest.TestCase):
|
||||
value = external_status.status_document(checkout=root, journal=journal, policy_file=policy)
|
||||
self.assertEqual(value["reason"], "recovery_journal")
|
||||
journal.unlink()
|
||||
with patch.object(external_status.os, "geteuid", return_value=0), \
|
||||
patch.object(external_status, "runtime_compatibility_reason", return_value="runtime_config_changed"), \
|
||||
patch.object(external_status, "latest_release_request") as select:
|
||||
value = external_status.status_document(
|
||||
checkout=root, journal=journal, policy_file=policy,
|
||||
main_lookup=lambda _path: "a" * 40, checkout_lookup=lambda _path: "c" * 40,
|
||||
main_lookup=lambda _path: "a" * 40,
|
||||
)
|
||||
self.assertEqual(value["reason"], "host_checkout_stale")
|
||||
self.assertEqual(value["reason"], "runtime_config_changed")
|
||||
select.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
import stat
|
||||
import sys
|
||||
@@ -49,6 +50,84 @@ def write_artifact(path, manifest=MANIFEST, extra=()):
|
||||
|
||||
|
||||
class ReleaseSourceTests(unittest.TestCase):
|
||||
def test_runtime_files_allow_bootstrap_a_to_release_b_only_when_configs_match(self):
|
||||
commit_b = "f" * 40
|
||||
contents = {
|
||||
"deploy/production/compose.yaml": b"compose-v1\n",
|
||||
"deploy/production/Caddyfile": b"caddy-v1\n",
|
||||
"infra/deploy/release-policy.json": b"policy-v1\n",
|
||||
}
|
||||
|
||||
def response(path):
|
||||
payload = contents[path]
|
||||
blob_sha = hashlib.sha1(b"blob " + str(len(payload)).encode() + b"\0" + payload).hexdigest()
|
||||
return {"type": "file", "path": path, "encoding": "base64",
|
||||
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": blob_sha}
|
||||
|
||||
def api(request):
|
||||
self.assertIn(f"?ref={commit_b}", request)
|
||||
path = request.split("/contents/", 1)[1].split("?", 1)[0]
|
||||
return response(path)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkout = Path(directory)
|
||||
(checkout / "deploy/production").mkdir(parents=True)
|
||||
(checkout / "deploy/production/compose.yaml").write_bytes(contents["deploy/production/compose.yaml"])
|
||||
(checkout / "deploy/production/Caddyfile").write_bytes(contents["deploy/production/Caddyfile"])
|
||||
policy = checkout / "policy.json"
|
||||
policy.write_bytes(contents["infra/deploy/release-policy.json"])
|
||||
self.assertIsNone(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
))
|
||||
|
||||
for relative in ("deploy/production/compose.yaml", "deploy/production/Caddyfile"):
|
||||
local_path = checkout / relative
|
||||
expected = contents[relative]
|
||||
local_path.write_bytes(b"changed\n")
|
||||
self.assertEqual(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
), "runtime_config_changed")
|
||||
local_path.write_bytes(expected)
|
||||
policy.write_bytes(b"policy-changed\n")
|
||||
self.assertEqual(release_source.runtime_compatibility_reason(
|
||||
commit_b, checkout=checkout, policy_file=policy, api=api,
|
||||
), "release_policy_changed")
|
||||
|
||||
def test_runtime_file_rejects_forged_git_blob_identity(self):
|
||||
payload = b"trusted content\n"
|
||||
response = {"type": "file", "path": "deploy/production/compose.yaml", "encoding": "base64",
|
||||
"content": base64.b64encode(payload).decode(), "size": len(payload), "sha": "a" * 40}
|
||||
with self.assertRaisesRegex(ValueError, "does not match"):
|
||||
release_source.repository_file(
|
||||
"deploy/production/compose.yaml", COMMIT,
|
||||
api=lambda _path: response,
|
||||
)
|
||||
|
||||
def test_runtime_compatibility_rejects_local_symlink_even_when_bytes_match(self):
|
||||
content = b"protected\n"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
checkout = Path(directory)
|
||||
config_dir = checkout / "deploy/production"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "compose.yaml").write_bytes(content)
|
||||
(config_dir / "Caddyfile").write_bytes(content)
|
||||
policy = checkout / "policy.json"
|
||||
policy.write_bytes(content)
|
||||
external = checkout / "outside.yaml"
|
||||
external.write_bytes(content)
|
||||
(config_dir / "Caddyfile").unlink()
|
||||
(config_dir / "Caddyfile").symlink_to(external)
|
||||
blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest()
|
||||
with self.assertRaisesRegex(ValueError, "not a regular file"):
|
||||
release_source.runtime_compatibility_reason(
|
||||
COMMIT, checkout=checkout, policy_file=policy,
|
||||
api=lambda _path: {
|
||||
"type": "file", "path": "deploy/production/compose.yaml",
|
||||
"encoding": "base64", "content": base64.b64encode(content).decode(),
|
||||
"size": len(content), "sha": blob_sha,
|
||||
},
|
||||
)
|
||||
|
||||
def test_verify_run_binds_run_id_workflow_path_event_branch_commit_and_main(self):
|
||||
def api(path):
|
||||
if path.endswith("/actions/runs/301"):
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PythonPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installerPath = Join-Path $PSScriptRoot 'install_windows_release_client.ps1'
|
||||
$fixtureScript = Join-Path $PSScriptRoot 'test_windows_release_launcher.py'
|
||||
$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||
$temporaryBase = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $temporaryBase).Path).TrimEnd('\')
|
||||
$temporaryName = 'akyldash-bundle-mode-' + [guid]::NewGuid().ToString('N')
|
||||
$temporaryRoot = [IO.Path]::Combine($temporaryBase, $temporaryName)
|
||||
if (Test-Path -LiteralPath $temporaryRoot) { throw 'Temporary fixture directory already exists.' }
|
||||
[void][IO.Directory]::CreateDirectory($temporaryRoot)
|
||||
$temporaryRootItem = Get-Item -LiteralPath $temporaryRoot -Force
|
||||
if (($temporaryRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Temporary fixture root must not be a reparse point.'
|
||||
}
|
||||
$temporaryRootCreated = $temporaryRootItem.CreationTimeUtc
|
||||
$temporaryOwnerSid = (Get-Acl -LiteralPath $temporaryRoot).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
if ($temporaryOwnerSid -cne $currentSid) { throw 'Temporary fixture root is not owned by the current user.' }
|
||||
$bundlePath = Join-Path $temporaryRoot 'fixture.zip'
|
||||
|
||||
function Get-VerifiedFixtureRoot([string]$Candidate) {
|
||||
$expectedRoot = [IO.Path]::Combine($temporaryBase, $temporaryName)
|
||||
$candidatePath = [IO.Path]::GetFullPath($Candidate)
|
||||
if ([string]::Compare($candidatePath, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0) {
|
||||
throw 'Refusing a fixture root outside the exact temporary base/name.'
|
||||
}
|
||||
$item = Get-Item -LiteralPath $candidatePath -Force
|
||||
if ($item -isnot [IO.DirectoryInfo] -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Fixture root must remain a real directory, not a reparse point.'
|
||||
}
|
||||
$resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $candidatePath).Path).TrimEnd('\')
|
||||
if ([string]::Compare($resolved, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0 -or
|
||||
[string]::Compare([IO.Path]::GetDirectoryName($resolved), $temporaryBase,
|
||||
[StringComparison]::OrdinalIgnoreCase) -ne 0 -or
|
||||
[string]::Compare([IO.Path]::GetFileName($resolved), $temporaryName,
|
||||
[StringComparison]::OrdinalIgnoreCase) -ne 0 -or
|
||||
$item.CreationTimeUtc -ne $temporaryRootCreated) {
|
||||
throw 'Fixture root was replaced, moved, or resolved outside its original identity.'
|
||||
}
|
||||
$ownerSid = (Get-Acl -LiteralPath $candidatePath).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
|
||||
throw 'Fixture root owner changed.'
|
||||
}
|
||||
return $resolved
|
||||
}
|
||||
|
||||
function Get-VerifiedFixtureEntry([string]$Path, [string]$ResolvedRoot) {
|
||||
$item = Get-Item -LiteralPath $Path -Force
|
||||
if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
|
||||
throw 'Fixture entry must be a regular file or directory without reparse points.'
|
||||
}
|
||||
$resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $item.FullName).Path)
|
||||
if (-not $resolved.StartsWith($ResolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Fixture entry resolved outside its verified temporary root.'
|
||||
}
|
||||
$ownerSid = (Get-Acl -LiteralPath $resolved).GetOwner(
|
||||
[Security.Principal.SecurityIdentifier]).Value
|
||||
if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) {
|
||||
throw 'Fixture entry is not owned by the current user.'
|
||||
}
|
||||
return Get-Item -LiteralPath $resolved -Force
|
||||
}
|
||||
|
||||
$wrongRootRejected = $false
|
||||
try { [void](Get-VerifiedFixtureRoot -Candidate $temporaryBase) }
|
||||
catch { $wrongRootRejected = $true }
|
||||
if (-not $wrongRootRejected) { throw 'Fixture cleanup accepted an incorrect root path.' }
|
||||
$passMessage = $null
|
||||
|
||||
try {
|
||||
$fixtureOutput = & $PythonPath -B -E -s -S $fixtureScript --write-bundle-fixture $bundlePath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'The temporary packager fixture failed.' }
|
||||
$fixture = ($fixtureOutput -join "`n") | ConvertFrom-Json
|
||||
|
||||
$gitObjectDirectory = Join-Path $temporaryRoot 'fixture-repository\.git\objects'
|
||||
$readonlyObject = Get-ChildItem -LiteralPath $gitObjectDirectory -File -Force -Recurse | Select-Object -First 1
|
||||
if ($null -eq $readonlyObject) { throw 'Packager fixture did not create a loose Git object.' }
|
||||
$verifiedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$verifiedObject = Get-VerifiedFixtureEntry -Path $readonlyObject.FullName -ResolvedRoot $verifiedRoot
|
||||
[IO.File]::SetAttributes($verifiedObject.FullName,
|
||||
[IO.FileAttributes]([int]$verifiedObject.Attributes -bor [int][IO.FileAttributes]::ReadOnly))
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$installerAst = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installerPath, [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count -gt 0) { throw 'Installer PowerShell syntax is invalid.' }
|
||||
$validatorAst = $installerAst.Find({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -ceq 'Assert-ReceiverBundleEntryMode'
|
||||
}, $true)
|
||||
if ($null -eq $validatorAst) { throw 'Installer bundle-mode validator was not found.' }
|
||||
. ([scriptblock]::Create($validatorAst.Extent.Text))
|
||||
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$mutationRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::AppendData -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
$pathReplacementRights = [System.Security.AccessControl.FileSystemRights](
|
||||
[int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::Delete -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor
|
||||
[int][System.Security.AccessControl.FileSystemRights]::TakeOwnership
|
||||
)
|
||||
foreach ($guardName in @('Assert-ProtectedPath', 'Assert-TrustedPath')) {
|
||||
$guardAst = $installerAst.Find({
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -ceq $guardName
|
||||
}, $true)
|
||||
if ($null -eq $guardAst) { throw "Installer ACL guard was not found: $guardName" }
|
||||
. ([scriptblock]::Create($guardAst.Extent.Text))
|
||||
}
|
||||
|
||||
$unknownSid = [Security.Principal.SecurityIdentifier]::new(
|
||||
'S-1-5-21-3141592653-2718281828-1618033988-987654')
|
||||
$unmapped = $false
|
||||
try { $null = $unknownSid.Translate([Security.Principal.NTAccount]) }
|
||||
catch [Security.Principal.IdentityNotMappedException] { $unmapped = $true }
|
||||
if (-not $unmapped) { throw 'The synthetic ACL SID unexpectedly resolves to an NTAccount.' }
|
||||
|
||||
$savedMockAcl = $script:akyldashAclFixture
|
||||
function Get-Acl([string]$LiteralPath) { return $script:akyldashAclFixture }
|
||||
try {
|
||||
$sidSystem = [Security.Principal.SecurityIdentifier]::new('S-1-5-18')
|
||||
$sidAdmins = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')
|
||||
$privateFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$privateFixtureAcl.SetAccessRuleProtection($true, $false)
|
||||
$privateFixtureAcl.SetOwner($identity.User)
|
||||
foreach ($sid in @($sidSystem, $sidAdmins, $identity.User)) {
|
||||
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sid, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
}
|
||||
$null = $privateFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$unknownSid, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$script:akyldashAclFixture = $privateFixtureAcl
|
||||
$privateRejected = $false
|
||||
try { Assert-ProtectedPath -Path $temporaryRoot -AllowOwnerWrite $true }
|
||||
catch {
|
||||
if ($_.Exception.Message -notlike 'Private state path ACL must grant only full control*') { throw }
|
||||
$privateRejected = $true
|
||||
}
|
||||
if (-not $privateRejected) { throw 'Unmapped SID was not rejected by the protected-path guard.' }
|
||||
|
||||
$trustedFixtureAcl = [System.Security.AccessControl.DirectorySecurity]::new()
|
||||
$trustedFixtureAcl.SetAccessRuleProtection($true, $false)
|
||||
$trustedFixtureAcl.SetOwner($sidSystem)
|
||||
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$sidSystem, [System.Security.AccessControl.FileSystemRights]::FullControl,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$null = $trustedFixtureAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
|
||||
$unknownSid, [System.Security.AccessControl.FileSystemRights]::WriteData,
|
||||
[System.Security.AccessControl.AccessControlType]::Allow))
|
||||
$script:akyldashAclFixture = $trustedFixtureAcl
|
||||
$trustedRejected = $false
|
||||
try { Assert-TrustedPath -Path $temporaryRoot }
|
||||
catch {
|
||||
if ($_.Exception.Message -notlike 'Protected path grants replacement/write access*') { throw }
|
||||
$trustedRejected = $true
|
||||
}
|
||||
if (-not $trustedRejected) { throw 'Unmapped SID was not rejected by the trusted-path guard.' }
|
||||
} finally {
|
||||
Remove-Item Function:\Get-Acl -ErrorAction SilentlyContinue
|
||||
if ($null -eq $savedMockAcl) { Remove-Variable -Name akyldashAclFixture -Scope Script -ErrorAction SilentlyContinue }
|
||||
else { $script:akyldashAclFixture = $savedMockAcl }
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
$stream = [IO.File]::OpenRead($bundlePath)
|
||||
try {
|
||||
$archive = [System.IO.Compression.ZipArchive]::new(
|
||||
$stream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
try {
|
||||
foreach ($entry in $archive.Entries) {
|
||||
Assert-ReceiverBundleEntryMode -Entry $entry
|
||||
}
|
||||
} finally { $archive.Dispose() }
|
||||
} finally { $stream.Dispose() }
|
||||
|
||||
$alteredPath = Join-Path $temporaryRoot 'altered-mode.zip'
|
||||
$sourceStream = [IO.File]::OpenRead($bundlePath)
|
||||
$targetStream = [IO.File]::Create($alteredPath)
|
||||
try {
|
||||
$sourceArchive = [System.IO.Compression.ZipArchive]::new(
|
||||
$sourceStream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
$targetArchive = [System.IO.Compression.ZipArchive]::new(
|
||||
$targetStream, [System.IO.Compression.ZipArchiveMode]::Create, $true)
|
||||
try {
|
||||
$changed = $false
|
||||
foreach ($entry in $sourceArchive.Entries) {
|
||||
$copy = $targetArchive.CreateEntry($entry.FullName, [System.IO.Compression.CompressionLevel]::NoCompression)
|
||||
$copy.ExternalAttributes = $entry.ExternalAttributes
|
||||
if (-not $changed) {
|
||||
$copy.ExternalAttributes = [int]-2120220672
|
||||
$changed = $true
|
||||
}
|
||||
$entryInput = $entry.Open()
|
||||
$entryOutput = $copy.Open()
|
||||
try { $entryInput.CopyTo($entryOutput) }
|
||||
finally { $entryOutput.Dispose(); $entryInput.Dispose() }
|
||||
}
|
||||
if (-not $changed) { throw 'Packager fixture had no entries to mutate.' }
|
||||
} finally { $targetArchive.Dispose(); $sourceArchive.Dispose() }
|
||||
} finally { $targetStream.Dispose(); $sourceStream.Dispose() }
|
||||
|
||||
$alteredStream = [IO.File]::OpenRead($alteredPath)
|
||||
try {
|
||||
$alteredArchive = [System.IO.Compression.ZipArchive]::new(
|
||||
$alteredStream, [System.IO.Compression.ZipArchiveMode]::Read, $true)
|
||||
try {
|
||||
$alteredEntry = $alteredArchive.Entries[0]
|
||||
$alteredMode = (([long]$alteredEntry.ExternalAttributes -shr 16) -band 0xFFFF)
|
||||
if ($alteredMode -ne 0x81A0) { throw 'Altered ZIP fixture does not contain the intended unsafe mode.' }
|
||||
$rejected = $false
|
||||
try { Assert-ReceiverBundleEntryMode -Entry $alteredEntry }
|
||||
catch {
|
||||
if ($_.Exception.Message -notmatch 'unsafe file mode') { throw }
|
||||
$rejected = $true
|
||||
}
|
||||
if (-not $rejected) { throw 'Installer validator accepted an altered ZIP entry mode.' }
|
||||
} finally { $alteredArchive.Dispose() }
|
||||
} finally { $alteredStream.Dispose() }
|
||||
|
||||
$passMessage = ("PASS: installer validator accepted the actual packager bundle for commit {0} (SHA-256 {1}) and rejected altered mode; read-only Git object cleanup succeeded." -f $fixture.commit, $fixture.sha256)
|
||||
} finally {
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$entries = @(Get-ChildItem -LiteralPath $resolvedRoot -Force -Recurse)
|
||||
$verifiedEntries = @(
|
||||
foreach ($entry in $entries) {
|
||||
Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
|
||||
}
|
||||
)
|
||||
foreach ($entry in ($verifiedEntries | Sort-Object { $_.FullName.Length } -Descending)) {
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
|
||||
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
$writableAttributes = [IO.FileAttributes]([int]$verified.Attributes -band
|
||||
(-bnot [int][IO.FileAttributes]::ReadOnly))
|
||||
[IO.File]::SetAttributes($verified.FullName, $writableAttributes)
|
||||
$verified = Get-VerifiedFixtureEntry -Path $verified.FullName -ResolvedRoot $resolvedRoot
|
||||
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
throw 'Could not clear the read-only attribute from the verified fixture entry.'
|
||||
}
|
||||
}
|
||||
}
|
||||
$remainingRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$remainingEntries = @(Get-ChildItem -LiteralPath $remainingRoot -Force -Recurse)
|
||||
$initialPaths = @($entries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object)
|
||||
$remainingPaths = @($remainingEntries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object)
|
||||
if (($initialPaths -join "`n") -cne ($remainingPaths -join "`n")) {
|
||||
throw 'Temporary fixture contents changed during cleanup; refusing deletion.'
|
||||
}
|
||||
foreach ($entry in ($remainingEntries | Sort-Object { $_.FullName.Length } -Descending)) {
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
$verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot
|
||||
if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) {
|
||||
throw 'Refusing fixture deletion while a verified entry remains read-only.'
|
||||
}
|
||||
if ($verified -is [IO.DirectoryInfo]) {
|
||||
[IO.Directory]::Delete($verified.FullName, $false)
|
||||
} elseif ($verified -is [IO.FileInfo]) {
|
||||
[IO.File]::Delete($verified.FullName)
|
||||
} else {
|
||||
throw 'Refusing to delete an unknown temporary fixture entry type.'
|
||||
}
|
||||
}
|
||||
$resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot
|
||||
[IO.Directory]::Delete($resolvedRoot, $false)
|
||||
}
|
||||
|
||||
Write-Output $passMessage
|
||||
@@ -0,0 +1,415 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
import io
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import prepare_windows_release_bundle as release_bundle
|
||||
from windows_release_launcher import WINDOWS_PATH, WINDOWS_TOOLS, configure_windows_tool_environment
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
class WindowsReleaseBootstrapTests(unittest.TestCase):
|
||||
def _installer_mutation_mask(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
match = re.search(r"\$mutationRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S)
|
||||
self.assertIsNotNone(match, "installer must declare one explicit FileSystemRights mutation mask")
|
||||
names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1)))
|
||||
values = {
|
||||
"WriteData": 0x0002,
|
||||
"AppendData": 0x0004,
|
||||
"WriteExtendedAttributes": 0x0010,
|
||||
"WriteAttributes": 0x0100,
|
||||
"DeleteSubdirectoriesAndFiles": 0x0040,
|
||||
"Delete": 0x10000,
|
||||
"ChangePermissions": 0x40000,
|
||||
"TakeOwnership": 0x80000,
|
||||
}
|
||||
self.assertEqual(names, set(values), "mutation mask must name each mutating filesystem right explicitly")
|
||||
mask = 0
|
||||
for name in names:
|
||||
mask |= values[name]
|
||||
return script, mask, values
|
||||
|
||||
def test_installer_acl_guard_rejects_each_mutation_right(self):
|
||||
script, mask, rights = self._installer_mutation_mask()
|
||||
trusted = {"S-1-5-18", "S-1-5-32-544", "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"}
|
||||
|
||||
def safe_acl(owner, entries):
|
||||
if owner not in trusted:
|
||||
return False
|
||||
for sid, access_type, granted, inherit_only in entries:
|
||||
if inherit_only:
|
||||
continue
|
||||
if access_type == "Allow" and granted & mask and sid not in trusted:
|
||||
return False
|
||||
return True
|
||||
|
||||
for name, right in rights.items():
|
||||
with self.subTest(right=name):
|
||||
self.assertNotEqual(mask & right, 0)
|
||||
self.assertFalse(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", right, False)]))
|
||||
self.assertTrue(safe_acl(
|
||||
"S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464",
|
||||
[("S-1-5-32-544", "Allow", mask, False), ("S-1-5-11", "Allow", 0x0001, False)],
|
||||
))
|
||||
self.assertTrue(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", mask, True)]))
|
||||
self.assertIn("([int]$_.FileSystemRights -band [int]$mutationRights)", script)
|
||||
self.assertIn("([int]$rule.FileSystemRights -band [int]$checkedRights)", script)
|
||||
self.assertIn("PropagationFlags]::InheritOnly", script)
|
||||
owner_branch = re.search(
|
||||
r"if \(\$AllowOwnerWrite\) \{\s*\$approvedOwners = @\((.*?)\)\s*\} else \{\s*\$approvedOwners = @\((.*?)\)\s*\}",
|
||||
script, re.S,
|
||||
)
|
||||
self.assertIsNotNone(owner_branch)
|
||||
self.assertIn("$identity.User.Value", owner_branch.group(1))
|
||||
self.assertNotIn("$identity.User.Value", owner_branch.group(2))
|
||||
self.assertIn("$trustedInstaller", owner_branch.group(2))
|
||||
|
||||
def test_parent_path_guard_allows_create_sibling_but_rejects_replacement_rights(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
match = re.search(r"\$pathReplacementRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S)
|
||||
self.assertIsNotNone(match)
|
||||
names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1)))
|
||||
values = {"DeleteSubdirectoriesAndFiles": 0x40, "Delete": 0x10000,
|
||||
"ChangePermissions": 0x40000, "TakeOwnership": 0x80000}
|
||||
self.assertEqual(names, set(values))
|
||||
mask = sum(values.values())
|
||||
self.assertEqual(mask & 0x0004, 0) # AppendData/CreateDirectories on C:\ creates a sibling.
|
||||
for right in values.values():
|
||||
self.assertNotEqual(mask & right, 0)
|
||||
|
||||
def test_installer_source_is_hash_bound_locked_and_never_runs_git(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("[IO.FileShare]::Read", script)
|
||||
self.assertIn("[System.IO.Compression.ZipArchive]::new($bundleStream", script)
|
||||
self.assertIn("Assert-ReceiverBundleEntryMode -Entry $zipEntry", script)
|
||||
self.assertIn("$entryMode -ne 0x8180", script)
|
||||
self.assertIn("$bundleHash -ne $ExpectedBundleSha256", script)
|
||||
self.assertIn("manifest.commit -cne $ExpectedCommit", script)
|
||||
self.assertIn("Get-GitObjectHex -Kind 'blob' -Bytes $data", script)
|
||||
self.assertIn("Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes", script)
|
||||
self.assertIn("Get-GitObjectHex -Kind 'tree' -Bytes $treeData", script)
|
||||
self.assertIn("Receiver bundle commit tree does not contain the exact source file", script)
|
||||
self.assertIn("objects/commit.bin", script)
|
||||
self.assertIn("objects/root-tree.bin", script)
|
||||
self.assertIn("$treeHeaders[0].Groups[1].Value -cne $seenTrees['']", script)
|
||||
self.assertIn("$verifiedFiles[$file.path] = $data", script)
|
||||
self.assertNotIn("(& $git", script)
|
||||
self.assertNotIn("Start-Process", script)
|
||||
self.assertNotIn("Copy-Item", script)
|
||||
self.assertLess(script.index("$bundleHash -ne $ExpectedBundleSha256"),
|
||||
script.index("[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])"))
|
||||
self.assertLess(script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }"),
|
||||
script.index("$bundleStream = [IO.File]::Open"))
|
||||
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
|
||||
self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)", script)
|
||||
self.assertIn("$acl.GetOwner([Security.Principal.SecurityIdentifier]).Value", script)
|
||||
self.assertGreaterEqual(script.count("$acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])"), 2)
|
||||
self.assertIn("$rule.IdentityReference.Value", script)
|
||||
self.assertNotIn(".Translate(", script)
|
||||
self.assertIn("Assert-TrustedPath $appRoot", script)
|
||||
self.assertIn("Assert-TrustedPath $programData -ParentsOnly $true", script)
|
||||
self.assertIn("Set-TrustedCodeOwner $app", script)
|
||||
self.assertIn("Set-TrustedCodeOwner $destination", script)
|
||||
for name in release_bundle.FILES:
|
||||
self.assertIn(f"files/{name}", script)
|
||||
|
||||
def test_windows_acl_harness_exercises_unmapped_sid_with_real_installer_guards(self):
|
||||
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("Assert-ProtectedPath", harness)
|
||||
self.assertIn("Assert-TrustedPath", harness)
|
||||
self.assertIn("IdentityNotMappedException", harness)
|
||||
self.assertIn("$unknownSid", harness)
|
||||
self.assertIn("$guardAst.Extent.Text", harness)
|
||||
self.assertIn("Unmapped SID was not rejected by the protected-path guard", harness)
|
||||
|
||||
def test_release_bundle_uses_exact_commit_objects_not_dirty_worktree_or_git_hooks(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
repository = root / "repo"
|
||||
repository.mkdir()
|
||||
self._git(repository, "init", "-b", "main")
|
||||
self._git(repository, "config", "user.name", "Fixture")
|
||||
self._git(repository, "config", "user.email", "fixture@example.invalid")
|
||||
for name, relative in release_bundle.FILES.items():
|
||||
path = repository / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(f"committed:{name}\n".encode())
|
||||
self._git(repository, "add", "--", *release_bundle.FILES.values())
|
||||
self._git(repository, "commit", "-m", "fixture")
|
||||
commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "switch", "-c", "not-main")
|
||||
self._git(repository, "commit", "--allow-empty", "-m", "alternate")
|
||||
alternate = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "switch", "main")
|
||||
with self.assertRaisesRegex(ValueError, "local main branch tip"):
|
||||
release_bundle.create_bundle(repository, alternate)
|
||||
|
||||
executable_path = repository / release_bundle.FILES["archive.py"]
|
||||
self._git(repository, "switch", "-c", "executable-mode")
|
||||
executable_path.chmod(0o755)
|
||||
self._git(repository, "add", "--", release_bundle.FILES["archive.py"])
|
||||
self._git(repository, "commit", "-m", "executable source")
|
||||
executable_commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "update-ref", "refs/heads/main", executable_commit)
|
||||
with self.assertRaisesRegex(ValueError, "not a regular Git blob"):
|
||||
release_bundle.create_bundle(repository, executable_commit)
|
||||
self._git(repository, "update-ref", "refs/heads/main", commit)
|
||||
self._git(repository, "switch", "main")
|
||||
|
||||
symlink_path = repository / release_bundle.FILES["archive.py"]
|
||||
self._git(repository, "switch", "-c", "symlink-source")
|
||||
symlink_path.unlink()
|
||||
symlink_path.symlink_to("missing-target")
|
||||
self._git(repository, "add", "--", release_bundle.FILES["archive.py"])
|
||||
self._git(repository, "commit", "-m", "symlink source")
|
||||
symlink_commit = self._git(repository, "rev-parse", "HEAD").decode().strip()
|
||||
self._git(repository, "update-ref", "refs/heads/main", symlink_commit)
|
||||
with self.assertRaisesRegex(ValueError, "not a regular Git blob"):
|
||||
release_bundle.create_bundle(repository, symlink_commit)
|
||||
self._git(repository, "update-ref", "refs/heads/main", commit)
|
||||
self._git(repository, "switch", "main")
|
||||
|
||||
edited = repository / release_bundle.FILES["receive_external.py"]
|
||||
edited.write_bytes(b"uncommitted attacker-controlled edit\n")
|
||||
hook_marker = root / "fsmonitor-ran"
|
||||
hook = root / "fsmonitor.sh"
|
||||
hook.write_text(f"#!/bin/sh\nprintf ran > {shlex.quote(str(hook_marker))}\n", encoding="utf-8")
|
||||
hook.chmod(0o700)
|
||||
self._git(repository, "config", "core.fsmonitor", str(hook))
|
||||
|
||||
payload = release_bundle.create_bundle(repository, commit)
|
||||
expected_sha256 = hashlib.sha256(payload).hexdigest()
|
||||
self.assertEqual(set(release_bundle.verify_bundle(payload, commit, expected_sha256)),
|
||||
set(release_bundle.FILES))
|
||||
self.assertFalse(hook_marker.exists(), "bundle creation must not invoke config-driven fsmonitor hooks")
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as bundle:
|
||||
manifest = json.loads(bundle.read("manifest.json"))
|
||||
self.assertEqual(manifest["commit"], commit)
|
||||
self.assertEqual(set(member.filename for member in bundle.infolist()),
|
||||
{"manifest.json", *(f"files/{name}" for name in release_bundle.FILES),
|
||||
*release_bundle.TREE_OBJECTS.values(), "objects/commit.bin"})
|
||||
self.assertEqual({(member.external_attr >> 16) & 0xFFFF for member in bundle.infolist()},
|
||||
{0o100600})
|
||||
self.assertEqual(release_bundle._git_object_sha("commit", bundle.read("objects/commit.bin")), commit)
|
||||
for tree in manifest["trees"]:
|
||||
object_name = release_bundle.TREE_OBJECTS[tree["path"]]
|
||||
self.assertEqual(release_bundle._git_object_sha("tree", bundle.read(object_name)), tree["sha1"])
|
||||
for name in release_bundle.FILES:
|
||||
self.assertEqual(bundle.read(f"files/{name}"), f"committed:{name}\n".encode())
|
||||
for member in ("objects/commit.bin", "objects/infra-tree.bin", "files/receive_external.py"):
|
||||
with self.subTest(member=member):
|
||||
corrupted = self._rewrite_bundle(payload, member, lambda content: content[:-1] + bytes([content[-1] ^ 1]))
|
||||
with self.assertRaises(ValueError):
|
||||
release_bundle.verify_bundle(corrupted, commit, hashlib.sha256(corrupted).hexdigest())
|
||||
|
||||
traversal = self._rewrite_bundle(payload, "../escape", lambda _unused: b"outside")
|
||||
with self.assertRaisesRegex(ValueError, "unexpected entries"):
|
||||
release_bundle.verify_bundle(traversal, commit, hashlib.sha256(traversal).hexdigest())
|
||||
duplicate = self._rewrite_bundle(payload, "manifest.json", lambda content: content, duplicate=True)
|
||||
with self.assertRaisesRegex(ValueError, "duplicate"):
|
||||
release_bundle.verify_bundle(duplicate, commit, hashlib.sha256(duplicate).hexdigest())
|
||||
symlink_entry = self._rewrite_bundle(payload, "files/archive.py", lambda content: content,
|
||||
external_attr=0o120777 << 16)
|
||||
with self.assertRaisesRegex(ValueError, "unsafe mode"):
|
||||
release_bundle.verify_bundle(symlink_entry, commit, hashlib.sha256(symlink_entry).hexdigest())
|
||||
altered_mode = self._rewrite_bundle(payload, "files/archive.py", lambda content: content,
|
||||
external_attr=0o100755 << 16)
|
||||
with self.assertRaisesRegex(ValueError, "unsafe mode"):
|
||||
release_bundle.verify_bundle(altered_mode, commit, hashlib.sha256(altered_mode).hexdigest())
|
||||
with self.assertRaisesRegex(ValueError, "invalid size"):
|
||||
release_bundle.verify_bundle(b"x" * (release_bundle.MAX_BUNDLE_BYTES + 1), commit, "0" * 64)
|
||||
|
||||
@staticmethod
|
||||
def _git(repository, *arguments):
|
||||
return subprocess.run(["git", "-C", str(repository), *arguments], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_bundle(payload, target, transform, *, duplicate=False, external_attr=None):
|
||||
output = io.BytesIO()
|
||||
with zipfile.ZipFile(io.BytesIO(payload)) as original, zipfile.ZipFile(output, "w") as altered:
|
||||
for item in original.infolist():
|
||||
content = original.read(item)
|
||||
if item.filename == target:
|
||||
content = transform(content)
|
||||
info = zipfile.ZipInfo(item.filename, item.date_time)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.external_attr = external_attr if item.filename == target and external_attr is not None else item.external_attr
|
||||
altered.writestr(info, content)
|
||||
if duplicate and item.filename == target:
|
||||
altered.writestr(info, content)
|
||||
if target not in {item.filename for item in original.infolist()}:
|
||||
info = zipfile.ZipInfo(target)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.external_attr = 0o100600 << 16
|
||||
altered.writestr(info, transform(b""))
|
||||
return output.getvalue()
|
||||
|
||||
def test_installer_has_no_child_processes_and_checks_tools_before_bundle_read(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
trusted_tools = script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }")
|
||||
bundle_open = script.index("$bundleStream = [IO.File]::Open")
|
||||
self.assertLess(trusted_tools, bundle_open)
|
||||
self.assertNotIn("(& $git", script)
|
||||
self.assertNotIn("Start-Process", script)
|
||||
self.assertNotIn("Process]::Start", script)
|
||||
self.assertIn("while ($null -ne $current)", script)
|
||||
self.assertIn("$current = $current.Parent", script)
|
||||
self.assertIn("$ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script)
|
||||
|
||||
def test_windows_bundle_mode_harness_uses_safe_python_and_validates_cleanup_target(self):
|
||||
harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("-B -E -s -S", harness)
|
||||
self.assertIn("$item.CreationTimeUtc -ne $temporaryRootCreated", harness)
|
||||
self.assertIn("$ownerSid -cne $temporaryOwnerSid", harness)
|
||||
self.assertIn("[IO.FileAttributes]::ReparsePoint", harness)
|
||||
self.assertIn("[IO.Directory]::Delete($resolvedRoot, $false)", harness)
|
||||
self.assertNotIn("[IO.Directory]::Delete($resolvedRoot, $true)", harness)
|
||||
self.assertIn("$wrongRootRejected", harness)
|
||||
self.assertIn("$readonlyObject", harness)
|
||||
self.assertIn("-bnot [int][IO.FileAttributes]::ReadOnly", harness)
|
||||
self.assertIn("[IO.File]::Delete($verified.FullName)", harness)
|
||||
self.assertIn("-not $resolved.StartsWith($ResolvedRoot + '\\'", harness)
|
||||
self.assertLess(harness.index("if ($alteredMode -ne 0x81A0)"),
|
||||
harness.index("Assert-ReceiverBundleEntryMode -Entry $alteredEntry"))
|
||||
|
||||
def test_windows_tool_environment_discards_path_and_git_overrides(self):
|
||||
environment = {
|
||||
"PATH": r"C:\Users\attacker\bin",
|
||||
"PATHEXT": ".COM;.EXE;.BAT",
|
||||
"PSModulePath": r"C:\Users\attacker\modules",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "core.sshCommand",
|
||||
"GIT_CONFIG_VALUE_0": "attacker.exe",
|
||||
"SAFE_SETTING": "preserved",
|
||||
}
|
||||
calls = []
|
||||
|
||||
def which(command):
|
||||
calls.append(command)
|
||||
return str(WINDOWS_TOOLS[command])
|
||||
|
||||
configure_windows_tool_environment(environ=environment, which=which)
|
||||
self.assertEqual(environment["PATH"], WINDOWS_PATH)
|
||||
self.assertEqual(environment["PATHEXT"], ".EXE")
|
||||
self.assertEqual(environment["SystemRoot"], r"C:\Windows")
|
||||
self.assertEqual(environment["PSModulePath"], r"C:\Windows\System32\WindowsPowerShell\v1.0\Modules")
|
||||
self.assertEqual(environment["SAFE_SETTING"], "preserved")
|
||||
self.assertFalse(any(key.upper().startswith("GIT_") for key in environment))
|
||||
self.assertEqual(set(calls), set(WINDOWS_TOOLS))
|
||||
|
||||
def test_windows_tool_environment_rejects_unexpected_resolution(self):
|
||||
environment = {"PATH": r"C:\attacker"}
|
||||
with self.assertRaisesRegex(RuntimeError, "protected path"):
|
||||
configure_windows_tool_environment(environ=environment, which=lambda _command: r"C:\attacker\ssh.exe")
|
||||
|
||||
def test_launcher_ignores_poisoned_working_directory_and_pythonpath(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
app = root / "Program Files" / "Akyldash"
|
||||
poison = root / "poison"
|
||||
app.mkdir(parents=True)
|
||||
poison.mkdir()
|
||||
launcher = app / "windows_release_launcher.py"
|
||||
launcher.write_bytes((ROOT / "infra/deploy/windows_release_launcher.py").read_bytes())
|
||||
(app / "receive_external.py").write_text("def main(): return 0\n", encoding="utf-8")
|
||||
(app / "archive.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
|
||||
(app / "external_session.py").write_text("ORIGIN = __file__\n", encoding="utf-8")
|
||||
marker = root / "sitecustomize-ran"
|
||||
(poison / "json.py").write_text("raise RuntimeError('poison json imported')\n", encoding="utf-8")
|
||||
(poison / "sitecustomize.py").write_text(
|
||||
"import os\nfrom pathlib import Path\nPath(os.environ['POISON_MARKER']).touch()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
driver = app / "driver.py"
|
||||
driver.write_text(
|
||||
"import sys\n"
|
||||
"from windows_release_launcher import configure_import_path\n"
|
||||
"configure_import_path(executable=sys.executable, app_dir=__import__('pathlib').Path(__file__).parent, "
|
||||
"python_root=__import__('pathlib').Path(sys.prefix), expected_executable=__import__('pathlib').Path(sys.executable), "
|
||||
"protected_app_root=__import__('pathlib').Path(__file__).parents[1])\n"
|
||||
"import json, receive_external, archive, external_session\n"
|
||||
"from pathlib import Path\n"
|
||||
"assert Path(json.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())\n"
|
||||
"assert Path(receive_external.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
|
||||
"assert Path(archive.__file__).resolve().parent == Path(__file__).parent.resolve()\n"
|
||||
"assert Path(external_session.__file__).resolve().parent == Path(__file__).parent.resolve()\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
environment = {**os.environ, "PYTHONPATH": str(poison), "POISON_MARKER": str(marker)}
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-E", "-s", "-S", str(driver)], cwd=poison,
|
||||
env=environment, text=True, capture_output=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse(marker.exists())
|
||||
|
||||
def test_disabled_task_uses_pinned_python_and_hardened_launcher(self):
|
||||
path = ROOT / "infra/deploy/windows-release-trigger.example.xml"
|
||||
tree = ET.parse(path)
|
||||
namespace = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"}
|
||||
task = tree.getroot()
|
||||
self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=namespace), "false")
|
||||
trigger = task.find("t:Triggers/t:CalendarTrigger", namespace)
|
||||
self.assertEqual(trigger.findtext("t:Enabled", namespaces=namespace), "false")
|
||||
self.assertEqual(task.findtext("t:Actions/t:Exec/t:Command", namespaces=namespace), r"C:\Python313\python.exe")
|
||||
arguments = task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=namespace)
|
||||
self.assertIn("-B -E -s -S", arguments)
|
||||
self.assertIn("windows_release_launcher.py", arguments)
|
||||
|
||||
def test_installer_never_registers_or_enables_task_and_uses_canonical_trigger(self):
|
||||
script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8")
|
||||
self.assertIn("release-trigger.json", script)
|
||||
self.assertIn("release-auto-apply.json", script)
|
||||
self.assertIn('"enabled":false', script)
|
||||
self.assertIn("C:\\Python313\\python.exe", script)
|
||||
self.assertIn("Get-AuthenticodeSignature", script)
|
||||
self.assertIn(".Status -ne 'Valid'", script)
|
||||
self.assertIn("client-$ExpectedCommit", script)
|
||||
self.assertIn("--auto-apply-prepared", script)
|
||||
self.assertIn("Akyldash-ReleaseAutoApply-disabled.xml", script)
|
||||
self.assertIn("Assert-TrustedPath $tool", script)
|
||||
self.assertIn(r"$git = 'C:\Program Files\Git\cmd\git.exe'", script)
|
||||
self.assertIn(r"$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'", script)
|
||||
self.assertIn(r"$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'", script)
|
||||
self.assertIn("$current = $current.Parent", script)
|
||||
self.assertIn("PropagationFlags]::InheritOnly", script)
|
||||
self.assertNotIn("Register-ScheduledTask", script)
|
||||
self.assertNotIn("Enable-ScheduledTask", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--write-bundle-fixture":
|
||||
output_path = Path(sys.argv[2])
|
||||
repository = output_path.parent / "fixture-repository"
|
||||
repository.mkdir()
|
||||
subprocess.run(["git", "-C", str(repository), "init", "-b", "main"], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(["git", "-C", str(repository), "config", "user.name", "Akyldash Test"], check=True)
|
||||
subprocess.run(["git", "-C", str(repository), "config", "user.email", "test@example.invalid"], check=True)
|
||||
for name, relative in release_bundle.FILES.items():
|
||||
path = repository / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(f"fixture:{name}\n".encode("ascii"))
|
||||
subprocess.run(["git", "-C", str(repository), "add", "--", *release_bundle.FILES.values()], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
subprocess.run(["git", "-C", str(repository), "commit", "-m", "bundle fixture"], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
commit = subprocess.run(["git", "-C", str(repository), "rev-parse", "HEAD"], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout.strip()
|
||||
payload = release_bundle.create_bundle(repository, commit)
|
||||
output_path.write_bytes(payload)
|
||||
print(json.dumps({"commit": commit, "sha256": hashlib.sha256(payload).hexdigest()}))
|
||||
else:
|
||||
unittest.main()
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"enabled": false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
||||
<RegistrationInfo>
|
||||
<Description>Apply only a previously prepared Akyldash release; disabled template requiring explicit owner activation.</Description>
|
||||
<URI>\Akyldash\ReleaseAutoApply</URI>
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
|
||||
<Enabled>false</Enabled>
|
||||
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
||||
<Repetition>
|
||||
<Interval>PT5M</Interval>
|
||||
<Duration>P1D</Duration>
|
||||
<StopAtDurationEnd>false</StopAtDurationEnd>
|
||||
</Repetition>
|
||||
</CalendarTrigger>
|
||||
</Triggers>
|
||||
<Principals>
|
||||
<Principal id="Owner">
|
||||
<UserId>REPLACE_WITH_OWNER_SID</UserId>
|
||||
<LogonType>InteractiveToken</LogonType>
|
||||
<RunLevel>LeastPrivilege</RunLevel>
|
||||
</Principal>
|
||||
</Principals>
|
||||
<Settings>
|
||||
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
||||
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
|
||||
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
||||
<AllowHardTerminate>true</AllowHardTerminate>
|
||||
<StartWhenAvailable>false</StartWhenAvailable>
|
||||
<Enabled>false</Enabled>
|
||||
<Hidden>false</Hidden>
|
||||
<WakeToRun>false</WakeToRun>
|
||||
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
||||
<Priority>7</Priority>
|
||||
</Settings>
|
||||
<Actions Context="Owner">
|
||||
<Exec>
|
||||
<Command>C:\Python313\python.exe</Command>
|
||||
<Arguments>-B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --auto-apply-prepared</Arguments>
|
||||
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
</Task>
|
||||
@@ -6,7 +6,7 @@
|
||||
</RegistrationInfo>
|
||||
<Triggers>
|
||||
<CalendarTrigger>
|
||||
<StartBoundary>2026-11-02T09:00:00</StartBoundary>
|
||||
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
|
||||
<Enabled>false</Enabled>
|
||||
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
|
||||
<Repetition>
|
||||
@@ -37,8 +37,8 @@
|
||||
</Settings>
|
||||
<Actions Context="Owner">
|
||||
<Exec>
|
||||
<Command>C:\Program Files\Akyldash\python.exe</Command>
|
||||
<Arguments>"C:\Program Files\Akyldash\receive_external.py" --once</Arguments>
|
||||
<Command>C:\Python313\python.exe</Command>
|
||||
<Arguments>-B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --once</Arguments>
|
||||
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
|
||||
</Exec>
|
||||
</Actions>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Harden the fixed Windows Python process before loading the release receiver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import ntpath
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PYTHON_EXE = Path(r"C:\Python313\python.exe")
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
WINDOWS_TOOLS = {
|
||||
"ssh.exe": Path(r"C:\Windows\System32\OpenSSH\ssh.exe"),
|
||||
"git.exe": Path(r"C:\Program Files\Git\cmd\git.exe"),
|
||||
"powershell.exe": Path(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"),
|
||||
}
|
||||
WINDOWS_PATH = os.pathsep.join((
|
||||
r"C:\Windows\System32\OpenSSH",
|
||||
r"C:\Program Files\Git\cmd",
|
||||
r"C:\Windows\System32",
|
||||
r"C:\Windows",
|
||||
))
|
||||
|
||||
|
||||
def configure_windows_tool_environment(*, environ=None, which=shutil.which,
|
||||
windows_root: Path = Path(r"C:\Windows")) -> None:
|
||||
"""Remove inherited command lookup and Git overrides before receiver imports."""
|
||||
environment = os.environ if environ is None else environ
|
||||
system_root = str(windows_root)
|
||||
environment["SystemRoot"] = system_root
|
||||
environment["PATH"] = WINDOWS_PATH
|
||||
environment["PATHEXT"] = ".EXE"
|
||||
environment["PSModulePath"] = ntpath.join(
|
||||
str(windows_root), "System32", "WindowsPowerShell", "v1.0", "Modules")
|
||||
for name in tuple(environment):
|
||||
if name.upper().startswith("GIT_"):
|
||||
del environment[name]
|
||||
for command, expected in WINDOWS_TOOLS.items():
|
||||
resolved = which(command)
|
||||
if resolved is None or os.path.normcase(os.path.abspath(resolved)) != os.path.normcase(os.path.abspath(expected)):
|
||||
raise RuntimeError(f"required Windows tool is missing from its protected path: {command}")
|
||||
|
||||
|
||||
def _is_beneath(path: Path, root: Path) -> bool:
|
||||
try:
|
||||
path.resolve().relative_to(root.resolve())
|
||||
return True
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def configure_import_path(*, executable: Path, app_dir: Path, python_root: Path,
|
||||
path_entries: list[str] | None = None,
|
||||
expected_executable: Path = PYTHON_EXE,
|
||||
protected_app_root: Path = Path(r"C:\Program Files")) -> None:
|
||||
"""Keep only stdlib paths and the protected app directory; discard CWD/env paths."""
|
||||
executable = Path(executable).resolve()
|
||||
app_dir = Path(app_dir).resolve()
|
||||
python_root = Path(python_root).resolve()
|
||||
if os.path.normcase(str(executable)) != os.path.normcase(str(expected_executable.resolve())):
|
||||
raise RuntimeError("release trigger must use the verified Python 3.13 installation")
|
||||
if not app_dir.is_dir() or not _is_beneath(app_dir, protected_app_root):
|
||||
raise RuntimeError("release receiver directory is outside protected Program Files")
|
||||
if not python_root.is_dir() or not _is_beneath(executable, python_root):
|
||||
raise RuntimeError("Python executable is outside its protected installation")
|
||||
entries = sys.path if path_entries is None else path_entries
|
||||
safe_stdlib = []
|
||||
for value in entries:
|
||||
if not value:
|
||||
continue
|
||||
candidate = Path(value)
|
||||
if _is_beneath(candidate, python_root):
|
||||
safe_stdlib.append(str(candidate))
|
||||
if not safe_stdlib:
|
||||
raise RuntimeError("Python standard library path is unavailable")
|
||||
sys.path[:] = safe_stdlib + [str(app_dir)]
|
||||
|
||||
|
||||
def _verify_module_origin(module, root: Path) -> None:
|
||||
origin = getattr(module, "__file__", None)
|
||||
if not isinstance(origin, str) or not _is_beneath(Path(origin), root):
|
||||
raise RuntimeError(f"release module was loaded outside its protected directory: {module.__name__}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
flags = sys.flags
|
||||
if not (flags.ignore_environment and flags.no_user_site and flags.no_site):
|
||||
raise RuntimeError("launch Python with -E -s -S to disable environment and site imports")
|
||||
configure_import_path(executable=Path(sys.executable), app_dir=APP_DIR,
|
||||
python_root=PYTHON_EXE.parent)
|
||||
if os.name == "nt":
|
||||
configure_windows_tool_environment()
|
||||
for name in ("json", "ssl", "subprocess", "pathlib"):
|
||||
module = importlib.import_module(name)
|
||||
_verify_module_origin(module, PYTHON_EXE.parent)
|
||||
receiver = importlib.import_module("receive_external")
|
||||
_verify_module_origin(receiver, APP_DIR)
|
||||
for name in ("archive", "external_session"):
|
||||
_verify_module_origin(importlib.import_module(name), APP_DIR)
|
||||
return receiver.main()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user