fix(build): исключить SIGPIPE из релизных гейтов под pipefail

Поиск с флагом -q прекращает чтение на первом совпадении и закрывает свой конец
канала. Продюсер, которому осталось что писать, получает SIGPIPE и завершается
кодом 141, а `set -o pipefail` делает 141 статусом всей конструкции:

    совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено»

Для утвердительных проверок это ложный FAIL. Для отрицательных — «такой
конструкции в коде нет» — ложный PASS: запрещённая конструкция найдена, а гейт
зелёный. Отрицательными проверками закреплена половина инвариантов приёмки,
включая запрет обхода тестов и запрет `pnpm audit --prod`.

Порог резкий: пока вывод продюсера помещается в буфер канала (64 KiB на Linux),
он не блокируется и успевает завершиться раньше, чем потребитель начнёт читать.
Замер, 60 прогонов на размер: до 60 KiB — 0 отказов, ровно на 64 KiB — 58/60,
от 96 KiB — 60/60. То есть проверка выглядит исправной ровно до первого
источника крупнее буфера, а такие файлы в репозитории уже есть.

- 56 мест переведены на here-string: `grep -q PATTERN <<<"$content"`;
- продюсеры-команды (ss|awk, dpkg-query, /proc/cpuinfo, systemctl
  list-unit-files) сначала читаются в переменную;
- введён code_has: десять отрицательных сканов держались на `|| true` внутри
  code_without_comments, гасившем 141, — то есть на побочном эффекте
  подавления ошибок, а не на заявленном свойстве;
- несуществующий путь в скане больше не означает успех: `2>/dev/null || true`
  превращал опечатку в пустой вывод, а пустой вывод для проверки «этого в коде
  нет» — это PASS. Проверка явная, а не через set -e: в контексте `! code_has`
  bash отключает errexit на весь вызов;
- возврат пайплайна запрещён отдельной приёмкой.
This commit is contained in:
2026-09-01 04:28:54 +05:00
parent 2259f7c847
commit a1c74caa0c
11 changed files with 251 additions and 82 deletions
+75 -18
View File
@@ -22,13 +22,45 @@ set -euo pipefail
# Решётка при этом отбрасывается ТОЛЬКО в shell. В шаблоне Vue строка вполне
# может начинаться с `#default="scope"` — это сокращение для v-slot, а не
# комментарий, и общий фильтр молча выбрасывал бы её из скана.
#
# Несуществующий путь — ОТКАЗ, а не пустой вывод. Прежнее `2>/dev/null || true`
# превращало опечатку в имени файла в пустую строку, а пустая строка для
# проверок вида «запрещённой конструкции здесь нет» означает успех. Скан,
# который молча проверяет пустоту, хуже отсутствующего: он отчитывается зелёным.
code_without_comments() {
[ -f "$1" ] || fail "code_without_comments: файл не найден: $1"
case "$1" in
*.sh) grep -vE '^[[:space:]]*#' "$1" 2>/dev/null || true ;;
*) grep -v '^[[:space:]]*//' "$1" 2>/dev/null || true ;;
# grep -v возвращает 1, когда КАЖДАЯ строка файла — комментарий. Это
# законный результат, а не отказ, поэтому статус здесь гасится.
*.sh) grep -vE '^[[:space:]]*#' "$1" || true ;;
*) grep -v '^[[:space:]]*//' "$1" || true ;;
esac
}
# code_has / code_lacks — поиск по КОДУ файла без пайплайна.
#
# Пайплайн здесь был ловушкой. Поиск с флагом -q прекращает чтение на первом
# совпадении, продюсер получает SIGPIPE, и под `pipefail` статус всей
# конструкции становится 141 — то есть НЕНУЛЕВЫМ именно тогда, когда совпадение
# НАЙДЕНО. Для отрицательных проверок («такой конструкции в коде нет») это
# давало бы ложный PASS: найденная запрещённая конструкция читалась бы как её
# отсутствие. Здесь беду удерживало `|| true` внутри помощника, гасившее 141, —
# то есть корректность гейта держалась на побочном эффекте подавления ошибок,
# а не на чём-то заявленном.
#
# Отказ чтения проверяется ЯВНО, а не через errexit. В контексте `! code_has …`
# bash отключает errexit на весь вызов, поэтому неудачное чтение прошло бы
# незамеченным и снова дало бы ложный PASS. `fail` выполняется в самой функции,
# а не в подоболочке подстановки, поэтому его `exit` останавливает сборку.
code_has() {
local file="$1"
shift
local code
code="$(code_without_comments "$file")" \
|| fail "acceptance: не удалось прочитать код файла $file"
grep -q "$@" <<<"$code"
}
# code_mentions_in отдаёт список файлов, где подстрока встречается В КОДЕ.
code_mentions_in() {
local needle="$1"
@@ -37,7 +69,7 @@ code_mentions_in() {
local file hits=""
while IFS= read -r file; do
[ -n "$file" ] || continue
if code_without_comments "$file" | grep -qF -- "$needle"; then
if code_has "$file" -F -- "$needle"; then
hits="${hits}${hits:+ }${file}"
fi
done <<EOF
@@ -437,7 +469,7 @@ run_clean_install_acceptance() {
# с паролем, уже напечатанным на экран.
grep -q 'errors.Is(err, dao.ErrAdminUserNotFound)' apps/cmd/reset.go \
|| fail "acceptance: reset-admin обязан различать sentinel-ошибки слоя данных"
! code_without_comments apps/cmd/reset.go | grep -qF 'hash, _ :=' \
! code_has apps/cmd/reset.go -F -- 'hash, _ :=' \
|| fail "acceptance: ошибка хеширования пароля снова проглатывается, в password_hash уедет пустая строка"
"$BUN_BIN" -e '
const source = require("node:fs").readFileSync("apps/cmd/reset.go", "utf8");
@@ -587,7 +619,7 @@ run_scheduler_lifecycle_acceptance() {
# Ключевая регрессия: смена настройки не имеет права ронять HTTP-сервер.
# Именно этот путь плодил планировщики — StopServer возвращал управление в
# `for { runServer() }`, а InitCron создавал новый cron.New() и терял ссылку.
! code_without_comments apps/controller/config.go | grep -q 'StopServer' \
! code_has apps/controller/config.go -- 'StopServer' \
|| fail "acceptance: смена настройки снова перезапускает HTTP-сервер вместо перепланирования джобы"
grep -q 'RescheduleResetTraffic' apps/controller/config.go \
|| fail "acceptance: расписание должно переноситься на месте, через RescheduleResetTraffic"
@@ -661,9 +693,9 @@ run_dead_config_acceptance() {
# Серверный конфиг Hysteria читается только из файла: ветка «сначала SQLite»
# была вторым источником истины и читалась ПЕРВОЙ.
! code_without_comments apps/service/config.go | grep -q 'Hysteria2Config)' \
! code_has apps/service/config.go -- 'Hysteria2Config)' \
|| fail "acceptance: чтение серверного конфига снова обращается к таблице config"
! code_without_comments apps/service/hysteria2_export.go | grep -q 'Hysteria2Config)' \
! code_has apps/service/hysteria2_export.go -- 'Hysteria2Config)' \
|| fail "acceptance: выгрузка серверного конфига снова обращается к таблице config"
log_step "Acceptance: share URI remark is derived, not stored as dead state"
@@ -679,7 +711,7 @@ run_dependency_hygiene_acceptance() {
# Ключевой канал утечки: сгенерированный пароль печатался в
# /var/log/hy2xs/hy2xs-admin.log, а этот файл отдаётся кнопкой выгрузки.
! code_without_comments apps/dao/sqlite.go | grep -q 'Initial admin password' \
! code_has apps/dao/sqlite.go -- 'Initial admin password' \
|| fail "acceptance: bootstrap-пароль снова пишется в журнал"
grep -q 'HY2XS_ADMIN_INITIAL_PASSWORD не задан' apps/dao/sqlite.go \
|| fail "acceptance: отсутствие HY2XS_ADMIN_INITIAL_PASSWORD должно быть отказом старта"
@@ -689,7 +721,7 @@ run_dependency_hygiene_acceptance() {
log_step "Acceptance: the admin log is sanitized on export, like the Hysteria one"
grep -q 'func ExportAdminLog' apps/service/journal.go \
|| fail "acceptance: журнал админки выгружается без санитайза"
! code_without_comments apps/controller/log.go | grep -q 'c.File(' \
! code_has apps/controller/log.go -- 'c.File(' \
|| fail "acceptance: журнал снова отдаётся сырым файлом мимо санитайза"
log_step "Acceptance: JWT stack is the maintained major line, with a fixed algorithm"
@@ -704,7 +736,7 @@ run_dependency_hygiene_acceptance() {
|| fail "acceptance: разбор токена должен ограничивать набор алгоритмов подписи"
log_step "Acceptance: password storage has exactly one format"
! code_without_comments apps/util/encrypt.go | grep -q 'SHA224String' \
! code_has apps/util/encrypt.go -- 'SHA224String' \
|| fail "acceptance: вернулась проверка пароля по несолёному SHA-224"
grep -q 'if !IsBcryptHash(storedHash)' apps/util/encrypt.go \
|| fail "acceptance: VerifyPassword обязан отклонять всё, кроме bcrypt"
@@ -726,12 +758,37 @@ run_dependency_hygiene_acceptance() {
# `--prod` не показывал уязвимости в vite/rollup, хотя они исполняются на
# build-машине и порождают production-бандл. Ровно такой класс и был найден
# (DOM clobbering в Rollup затрагивал генерируемый бандл).
! code_without_comments tools/build/lib/security.sh | grep -q 'audit --prod' \
! code_has tools/build/lib/security.sh -- 'audit --prod' \
|| fail "acceptance: гейт зависимостей снова проверяет только production-подграф npm"
code_without_comments tools/build/lib/security.sh \
| grep -q 'audit --audit-level "\$PNPM_AUDIT_LEVEL"' \
code_has tools/build/lib/security.sh -- 'audit --audit-level "\$PNPM_AUDIT_LEVEL"' \
|| fail "acceptance: гейт зависимостей обязан проверять весь lock-граф frontend"
log_step "Acceptance: release gates never pipe into an early-exiting matcher"
# Поиск с флагом -q прекращает чтение на ПЕРВОМ совпадении и закрывает свой
# конец канала. Продюсер, у которого осталось что писать, получает SIGPIPE и
# завершается кодом 141, а `set -o pipefail` делает 141 статусом всей
# конструкции. Получается инверсия смысла: «совпадение найдено» становится
# ненулевым кодом.
#
# Для утвердительных проверок это ложный FAIL — гейт отвергает корректный
# артефакт. Для отрицательных («такой конструкции в коде нет») — ЛОЖНЫЙ PASS:
# найденная запрещённая конструкция читается как её отсутствие, и гейт
# отчитывается зелёным ровно тогда, когда обязан был остановить сборку.
#
# Порог не гипотетический: пока весь вывод продюсера помещается в буфер канала
# (64 KiB на Linux), он успевает записать всё и не блокируется, поэтому такая
# проверка годами выглядит исправной. Первый же источник крупнее буфера
# переворачивает её результат — а в этом репозитории такие файлы уже есть.
#
# Замена — here-string: `matcher PATTERN <<<"$content"`. Пайплайна нет, статус
# у конструкции ровно один, и pipefail к нему не применяется.
local piped_matcher
piped_matcher="$(grep -rn -E '\|[[:space:]]*grep[[:space:]]+-[A-Za-z]*q' \
tools/build/lib tools/build/build.sh tools/test tools/legacy package || true)"
[ -z "$piped_matcher" ] \
|| fail "acceptance: пайплайн в grep -q под pipefail вернулся; используйте here-string:
$piped_matcher"
log_step "Acceptance: the release is built with a mandatory test gate"
# SKIP_TESTS=true доходил до конца сборки и выдавал внешне неотличимый
# production-артефакт: ни metadata, ни финальная приёмка не проверяли, что
@@ -1229,19 +1286,19 @@ run_secret_channel_acceptance() {
log_code="$(grep -v '^[[:space:]]*//' apps/middleware/log.go || true)"
server_code="$(grep -v '^[[:space:]]*//' apps/cmd/server.go || true)"
printf '%s\n' "$log_code" | grep -q 'c.Request.URL.Path' \
grep -q 'c.Request.URL.Path' <<<"$log_code" \
|| fail "acceptance: the request logger must log the path, not RequestURI"
! printf '%s\n' "$log_code" | grep -q 'RequestURI' \
! grep -q 'RequestURI' <<<"$log_code" \
|| fail "acceptance: RequestURI carries the machine token in its query string"
! grep -rq 'ReqUri' apps/model/vo apps/frontend/src/api \
|| fail "acceptance: the reqUri log field came back"
log_step "Acceptance: the admin has exactly one HTTP log channel"
! printf '%s\n' "$server_code" | grep -q 'gin.Default()' \
! grep -q 'gin.Default()' <<<"$server_code" \
|| fail "acceptance: gin.Default() logs the query string to stdout and then to journald"
printf '%s\n' "$server_code" | grep -q 'gin.New()' \
grep -q 'gin.New()' <<<"$server_code" \
|| fail "acceptance: the admin engine must be built with gin.New()"
printf '%s\n' "$server_code" | grep -q 'gin.Recovery()' \
grep -q 'gin.Recovery()' <<<"$server_code" \
|| fail "acceptance: gin.New() must still install the recovery middleware"
log_step "Acceptance: exported logs are sanitized on both sides"