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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user