From a1c74caa0c398d240f68687d0905e0c501edd9e8 Mon Sep 17 00:00:00 2001 From: Crimson Date: Tue, 1 Sep 2026 04:28:54 +0500 Subject: [PATCH] =?UTF-8?q?fix(build):=20=D0=B8=D1=81=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20SIGPIPE=20=D0=B8=D0=B7=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BB=D0=B8=D0=B7=D0=BD=D1=8B=D1=85=20=D0=B3=D0=B5=D0=B9?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=BF=D0=BE=D0=B4=20pipefail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поиск с флагом -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 на весь вызов; - возврат пайплайна запрещён отдельной приёмкой. --- CHANGELOG.md | 43 ++++++++++++++ docs/11-testing-and-acceptance.md | 44 +++++++++++++++ tools/build/README.md | 2 +- tools/build/lib/acceptance.sh | 93 +++++++++++++++++++++++++------ tools/build/lib/deps.sh | 8 ++- tools/build/lib/hysteria.sh | 22 +++++--- tools/build/lib/security.sh | 2 +- tools/build/lib/verify.sh | 82 +++++++++++++-------------- tools/build/lib/versions.sh | 8 +-- tools/legacy/purge-v0.sh | 4 +- tools/test/e2e-hysteria.sh | 25 ++++++--- 11 files changed, 251 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee870d5..6fb1541 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,49 @@ Hardening-проход перед релизом `1.0.0`. Основная те асинхронного исполнителя, в трёх местах принимал за доказательство отсутствие наблюдения. +Восьмой проход — сами релизные гейты. Проверка, которая инвертирует свой +результат на достаточно большом входе, опаснее отсутствующей: отсутствующая +ничего не обещает. + +### Исправлено — гейты сборки + +- **Пайплайн в поиск с флагом `-q` под `pipefail` инвертирует смысл проверки.** + Такой поиск прекращает чтение на первом совпадении и закрывает свой конец + канала; продюсер, которому осталось что писать, получает `SIGPIPE` и + завершается кодом 141, а `pipefail` делает 141 статусом всей конструкции: + + ```text + совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено» + ``` + + Для утвердительных проверок это ложный FAIL. Для отрицательных — «такой + конструкции в коде нет» — **ложный PASS**: запрещённая конструкция найдена, а + гейт зелёный. Именно отрицательными проверками закреплена половина инвариантов + приёмки, включая запрет обхода тестов и запрет `pnpm audit --prod`. + + Порог резкий и измеримый: пока вывод продюсера помещается в буфер канала + (64 KiB на Linux), он не блокируется и успевает завершиться раньше, чем + потребитель начнёт читать, — 0 отказов на 60 прогонах для любого размера до + 60 KiB и 58/60 ровно на 64 KiB. То есть проверка выглядит исправной ровно до + первого источника крупнее буфера, а такие файлы в репозитории уже есть. + + Все 56 мест переведены на here-string (`grep -q PATTERN <<<"$content"`); + продюсеры-команды (`ss | awk`, `dpkg-query`, `/proc/cpuinfo`, + `systemctl list-unit-files`) сначала читаются в переменную. Возврат пайплайна + запрещён отдельной приёмкой. + +- **Корректность десяти отрицательных сканов держалась на `|| true`.** В + `code_without_comments` подавление статуса гасило и SIGPIPE-141, поэтому + сканы работали — но по причине, которую никто не заявлял и которую снял бы + первый же, кто убрал бы маскировку ошибок. Введён `code_has`, читающий код в + переменную отдельным оператором. + +- **Несуществующий путь в скане означал успех.** `2>/dev/null || true` в + `code_without_comments` превращал опечатку в имени файла в пустой вывод, а + пустой вывод для проверки «запрещённой конструкции здесь нет» — это PASS. + Теперь отсутствующий файл останавливает сборку. Проверка явная, а не через + `set -e`: в контексте `! code_has …` bash отключает `errexit` на весь вызов. + ### Исправлено — барьер покоя и контракт транзиентного таймера - **Отказ запроса к systemd выдавался за отсутствие guard'а.** Листинг diff --git a/docs/11-testing-and-acceptance.md b/docs/11-testing-and-acceptance.md index 0127a97..b98e948 100644 --- a/docs/11-testing-and-acceptance.md +++ b/docs/11-testing-and-acceptance.md @@ -1447,3 +1447,47 @@ hy2xs-orchestrator doctor 73. отработавший таймер не блокирует операцию навсегда: `RemainAfterElapse=no` выгружает его, а барьер дополнительно опознаёт `SubState=elapsed` у `*.timer` как покой 74. обещанное окно отката — контракт systemd, а не намерение: у транзиентного таймера явно задан `AccuracySec=1s`, иначе умолчание `AccuracySec=1min` превращало «45 секунд» в 45–105 75. состояние guard читает один наблюдатель: `status` берёт его у того же кода, что и барьер, и сообщает `unknown` вместо тихого «guard'ов нет» при отказе systemd +76. ни один релизный гейт не подаёт вывод в поиск с флагом `-q` через пайплайн: под `set -o pipefail` оборванный продюсер отдаёт 141, и «совпадение найдено» превращается в ненулевой код — для отрицательных проверок это ложный PASS. Сравнение идёт через here-string, и возврат пайплайна запрещён отдельной приёмкой + +### Почему пайплайн в `grep -q` запрещён + +Поиск с флагом `-q` прекращает чтение на **первом** совпадении и закрывает свой +конец канала. Продюсер, которому осталось что писать, получает `SIGPIPE` и +завершается кодом 141, а `set -o pipefail` делает 141 статусом всей +конструкции. Смысл инвертируется: + +```text +совпадение НАЙДЕНО -> продюсер оборван -> статус 141 -> «не найдено» +``` + +Для утвердительной проверки это ложный FAIL — гейт отвергает корректный +артефакт. Для отрицательной («такой конструкции в коде нет») — **ложный PASS**: +запрещённая конструкция найдена, а гейт зелёный. + +Порог измерим и резкий. Пока весь вывод продюсера помещается в буфер канала — +64 KiB на Linux, — он записывает всё, не блокируясь, и успевает завершиться +раньше, чем потребитель вообще начнёт читать. Замер на 60 прогонах каждого +размера: + +```text + 4 KiB … 60 KiB отказов 0 + 64 KiB отказов 58/60 + 96 KiB и больше отказов 60/60 +``` + +Поэтому такая проверка годами выглядит исправной, а переворачивается на первом +источнике крупнее буфера. В этом репозитории файлы такого размера уже есть +(`tools/build/lib/acceptance.sh` — 123 KiB, `orchestrator/src/steps/firewall.ts` +— 67 KiB). + +Правильная форма — here-string, у которого пайплайна нет вовсе: + +```bash +grep -q 'PATTERN' <<<"$content" || fail "..." +``` + +Для содержимого файла есть `code_has FILE [флаги] -- PATTERN`: он читает код без +комментариев в переменную **отдельным оператором** и сравнивает через +here-string. Отдельный оператор важен: в контексте `! code_has …` bash отключает +`errexit` на весь вызов, поэтому неудачное чтение проверяется явно, а не +рассчитывает на `set -e`. diff --git a/tools/build/README.md b/tools/build/README.md index 2097ac5..9e7c91f 100644 --- a/tools/build/README.md +++ b/tools/build/README.md @@ -343,7 +343,7 @@ NODE_OPTIONS="--max-old-space-size=3072" ./tools/build/build.sh Проверить AVX2: ```bash -grep -m1 '^flags' /proc/cpuinfo | grep -qw avx2 && echo 'CPU has AVX2' || echo 'CPU has NO AVX2; Bun baseline is required' +grep -qw avx2 <<<"$(grep -m1 '^flags' /proc/cpuinfo)" && echo 'CPU has AVX2' || echo 'CPU has NO AVX2; Bun baseline is required' ``` Проверить Bun: diff --git a/tools/build/lib/acceptance.sh b/tools/build/lib/acceptance.sh index 19bad65..a39ba26 100644 --- a/tools/build/lib/acceptance.sh +++ b/tools/build/lib/acceptance.sh @@ -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 </dev/null | grep -q 'install ok installed'; then + local pkg_status + pkg_status="$(dpkg-query -W -f='${Status}' "$pkg" 2>/dev/null)" || pkg_status="" + if ! grep -q 'install ok installed' <<<"$pkg_status"; then missing+=("$pkg") fi done @@ -135,7 +137,9 @@ ensure_go() { } cpu_has_avx2() { - grep -m1 '^flags' /proc/cpuinfo 2>/dev/null | grep -qw avx2 + local flags + flags="$(grep -m1 '^flags' /proc/cpuinfo 2>/dev/null)" || flags="" + grep -qw avx2 <<<"$flags" } select_bun_artifact() { diff --git a/tools/build/lib/hysteria.sh b/tools/build/lib/hysteria.sh index cfe0fbe..2a2b54f 100644 --- a/tools/build/lib/hysteria.sh +++ b/tools/build/lib/hysteria.sh @@ -30,7 +30,7 @@ HYSTERIA_GATE_TIMEOUT_SECONDS="${HYSTERIA_GATE_TIMEOUT_SECONDS:-15}" validate_hysteria_version_format() { local value="$1" - printf '%s' "$value" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$value" \ || fail "invalid Hysteria version format: $value (expected vX.Y.Z)" } @@ -41,7 +41,7 @@ validate_hysteria_sha256_format() { fail "HYSTERIA_ARTIFACT_SHA256 must be a real release sha256" ;; esac - printf '%s' "$value" | grep -Eq '^[a-fA-F0-9]{64}$' \ + grep -Eq '^[a-fA-F0-9]{64}$' <<<"$value" \ || fail "HYSTERIA_ARTIFACT_SHA256 must be a 64-char hex SHA256, got: $value" } @@ -198,7 +198,12 @@ assert_gate_port_is_free() { local proto_flag="$2" if command -v ss >/dev/null 2>&1; then - if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}$"; then + # Вывод собирается ДО поиска: `grep -q` обрывает чтение на первом совпадении, + # и под `pipefail` SIGPIPE у продюсера превратил бы найденный занятый порт в + # ненулевой статус, то есть в вывод «порт свободен». + local sockets + sockets="$(ss -H "$proto_flag" 2>/dev/null | awk '{print $4}')" || sockets="" + if grep -Eq "[:.]${port}$" <<<"$sockets"; then fail "compatibility gate port $port is already in use; override HYSTERIA_GATE_PORT/HYSTERIA_GATE_STATS_PORT" fi fi @@ -246,10 +251,13 @@ gate_run_single_profile() { if ! kill -0 "$gate_pid" 2>/dev/null; then break fi - if command -v ss >/dev/null 2>&1 \ - && ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${HYSTERIA_GATE_PORT}$"; then - ready="true" - break + if command -v ss >/dev/null 2>&1; then + local udp_sockets + udp_sockets="$(ss -H -lun 2>/dev/null | awk '{print $4}')" || udp_sockets="" + if grep -Eq "[:.]${HYSTERIA_GATE_PORT}$" <<<"$udp_sockets"; then + ready="true" + break + fi fi sleep 1 waited=$((waited + 1)) diff --git a/tools/build/lib/security.sh b/tools/build/lib/security.sh index 81ef365..6bc7196 100644 --- a/tools/build/lib/security.sh +++ b/tools/build/lib/security.sh @@ -129,7 +129,7 @@ run_frontend_vulnerability_gate() { # pnpm audit ходит в реестр npm. Недоступность реестра — это отказ проверки, # а не её отрицательный результат, и молча пропускать его нельзя. - if printf '%s' "$report" | grep -qiE 'ERR_PNPM_AUDIT_ENDPOINT|ENOTFOUND|ECONNREFUSED|network|getaddrinfo'; then + if grep -qiE 'ERR_PNPM_AUDIT_ENDPOINT|ENOTFOUND|ECONNREFUSED|network|getaddrinfo' <<<"$report"; then fail "pnpm audit не смог обратиться к реестру npm — проверка не выполнена: $report" fi diff --git a/tools/build/lib/verify.sh b/tools/build/lib/verify.sh index 897c09e..7a450fa 100644 --- a/tools/build/lib/verify.sh +++ b/tools/build/lib/verify.sh @@ -45,22 +45,22 @@ verify_archive() { local listing listing="$(tar -tzf "$archive")" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/install.sh$' || fail "archive missing install.sh" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/LICENSE$' || fail "archive missing LICENSE" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/orchestrator/hy2xs-orchestrator$' || fail "archive missing orchestrator artifact" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/ui/hy2xs-admin/hy2xs-admin$' || fail "archive missing bundled UI binary" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/systemd/hysteria-server.service$' || fail "archive missing hysteria systemd unit" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/systemd/hy2xs-admin.service$' || fail "archive missing admin systemd unit" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/hysteria/config.yaml.tpl$' || fail "archive missing Hysteria config template" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/config/hy2xs.env$' || fail "archive missing canonical runtime config" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/templates/env/post-install.env.tpl$' || fail "archive missing post-install env template" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/checksums.txt$' || fail "archive missing checksums" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.version$' || fail "archive missing pinned Hysteria version" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.url$' || fail "archive missing pinned Hysteria artifact url" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.sha256$' || fail "archive missing pinned Hysteria sha256" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/hysteria.resolution$' || fail "archive missing Hysteria resolution marker" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.release_line$' || fail "archive missing product release line" - printf '%s\n' "$listing" | grep -q '^hy2xs-install/metadata/package.config_schema_version$' || fail "archive missing packaged config schema version" + grep -q '^hy2xs-install/install.sh$' <<<"$listing" || fail "archive missing install.sh" + grep -q '^hy2xs-install/LICENSE$' <<<"$listing" || fail "archive missing LICENSE" + grep -q '^hy2xs-install/orchestrator/hy2xs-orchestrator$' <<<"$listing" || fail "archive missing orchestrator artifact" + grep -q '^hy2xs-install/ui/hy2xs-admin/hy2xs-admin$' <<<"$listing" || fail "archive missing bundled UI binary" + grep -q '^hy2xs-install/systemd/hysteria-server.service$' <<<"$listing" || fail "archive missing hysteria systemd unit" + grep -q '^hy2xs-install/systemd/hy2xs-admin.service$' <<<"$listing" || fail "archive missing admin systemd unit" + grep -q '^hy2xs-install/templates/hysteria/config.yaml.tpl$' <<<"$listing" || fail "archive missing Hysteria config template" + grep -q '^hy2xs-install/config/hy2xs.env$' <<<"$listing" || fail "archive missing canonical runtime config" + grep -q '^hy2xs-install/templates/env/post-install.env.tpl$' <<<"$listing" || fail "archive missing post-install env template" + grep -q '^hy2xs-install/metadata/checksums.txt$' <<<"$listing" || fail "archive missing checksums" + grep -q '^hy2xs-install/metadata/hysteria.version$' <<<"$listing" || fail "archive missing pinned Hysteria version" + grep -q '^hy2xs-install/metadata/hysteria.url$' <<<"$listing" || fail "archive missing pinned Hysteria artifact url" + grep -q '^hy2xs-install/metadata/hysteria.sha256$' <<<"$listing" || fail "archive missing pinned Hysteria sha256" + grep -q '^hy2xs-install/metadata/hysteria.resolution$' <<<"$listing" || fail "archive missing Hysteria resolution marker" + grep -q '^hy2xs-install/metadata/package.release_line$' <<<"$listing" || fail "archive missing product release line" + grep -q '^hy2xs-install/metadata/package.config_schema_version$' <<<"$listing" || fail "archive missing packaged config schema version" # Контракт установки проверяется структурно: install.sh — read-only bootstrap # целиком, у PHASE 1 ровно один владелец — оркестратор. @@ -73,9 +73,9 @@ verify_archive() { # Владение мутацией нельзя было отследить, потому что мутировали двое. local packaged_installer mutation_hits packaged_installer="$(tar -xOzf "$archive" hy2xs-install/install.sh)" - printf '%s\n' "$packaged_installer" | grep -q 'preflight-install' \ + grep -q 'preflight-install' <<<"$packaged_installer" \ || fail "packaged install.sh does not run the read-only preflight" - printf '%s\n' "$packaged_installer" | grep -q 'preflight-install --package-dir' \ + grep -q 'preflight-install --package-dir' <<<"$packaged_installer" \ || fail "packaged install.sh: could not locate the preflight invocation" # Комментарии отбрасываются: строка, ОБЪЯСНЯЮЩАЯ, почему установщик больше @@ -87,25 +87,25 @@ verify_archive() { || fail "packaged install.sh must not mutate the host; PHASE 1 belongs to the orchestrator alone. Found: $mutation_hits" - printf '%s\n' "$packaged_installer" | grep -q 'exec .*install --package-dir' \ + grep -q 'exec .*install --package-dir' <<<"$packaged_installer" \ || fail "packaged install.sh must hand the whole mutating phase to the orchestrator via exec" local license_text license_text="$(tar -xOzf "$archive" hy2xs-install/LICENSE)" - printf '%s\n' "$license_text" | grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' \ + grep -q 'GNU AFFERO GENERAL PUBLIC LICENSE' <<<"$license_text" \ || fail "packaged LICENSE is not the GNU Affero General Public License" - printf '%s\n' "$license_text" | grep -q 'Version 3, 19 November 2007' \ + grep -q 'Version 3, 19 November 2007' <<<"$license_text" \ || fail "packaged LICENSE is not AGPL version 3" local env_content env_content="$(tar -xOzf "$archive" hy2xs-install/config/hy2xs.env)" - printf '%s\n' "$env_content" | grep -q 'replace-with-your-domain.example' && fail "packaged hy2xs.env contains placeholder domain" - printf '%s\n' "$env_content" | grep -q 'replace-with-your-email@example.com' && fail "packaged hy2xs.env contains placeholder email" + grep -q 'replace-with-your-domain.example' <<<"$env_content" && fail "packaged hy2xs.env contains placeholder domain" + grep -q 'replace-with-your-email@example.com' <<<"$env_content" && fail "packaged hy2xs.env contains placeholder email" local hysteria_tpl hysteria_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/hysteria/config.yaml.tpl)" - printf '%s\n' "$hysteria_tpl" | grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' || fail "hysteria auth template must include machine access_token" - printf '%s\n' "$hysteria_tpl" | grep -q '{{OBFS_BLOCK}}' || fail "hysteria template must render a typed obfs block" + grep -q '/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}' <<<"$hysteria_tpl" || fail "hysteria auth template must include machine access_token" + grep -q '{{OBFS_BLOCK}}' <<<"$hysteria_tpl" || fail "hysteria template must render a typed obfs block" # Замороженная версия обязана совпадать во всех местах пакета. local pinned_version pinned_sha pinned_url meta_env @@ -114,24 +114,24 @@ $mutation_hits" pinned_url="$(tar -xOzf "$archive" hy2xs-install/metadata/hysteria.url | tr -d '\n')" meta_env="$(tar -xOzf "$archive" hy2xs-install/metadata/package.env)" - printf '%s' "$pinned_version" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' \ + grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' <<<"$pinned_version" \ || fail "packaged Hysteria version is not a concrete vX.Y.Z: $pinned_version" - printf '%s' "$pinned_sha" | grep -Eq '^[a-f0-9]{64}$' \ + grep -Eq '^[a-f0-9]{64}$' <<<"$pinned_sha" \ || fail "packaged Hysteria sha256 is not a 64-char hex digest" - printf '%s' "$pinned_url" | grep -Fq "$pinned_version" \ + grep -Fq "$pinned_version" <<<"$pinned_url" \ || fail "packaged Hysteria artifact url does not point at the pinned version: $pinned_url" - if printf '%s' "$pinned_url" | grep -q 'latest'; then + if grep -q 'latest' <<<"$pinned_url"; then fail "packaged Hysteria artifact url must be immutable, not a moving latest: $pinned_url" fi - printf '%s\n' "$meta_env" | grep -q "^hysteria_version=${pinned_version}$" \ + grep -q "^hysteria_version=${pinned_version}$" <<<"$meta_env" \ || fail "metadata package.env disagrees with metadata/hysteria.version" - printf '%s\n' "$meta_env" | grep -q "^hysteria_artifact_sha256=${pinned_sha}$" \ + grep -q "^hysteria_artifact_sha256=${pinned_sha}$" <<<"$meta_env" \ || fail "metadata package.env disagrees with metadata/hysteria.sha256" local post_install_tpl post_install_tpl="$(tar -xOzf "$archive" hy2xs-install/templates/env/post-install.env.tpl)" - printf '%s\n' "$post_install_tpl" | grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' || fail "post-install env template must include machine access_token in HY2_AUTH_URL" + grep -q '^HY2_AUTH_URL=http://127.0.0.1:{{UI_PORT}}/internal/hysteria/auth?access_token={{HYSTERIA_API_SECRET}}$' <<<"$post_install_tpl" || fail "post-install env template must include machine access_token in HY2_AUTH_URL" local tmp tmp="$(mktemp -d)" @@ -144,22 +144,22 @@ $mutation_hits" local meta meta="$(cat "$tmp/hy2xs-install/metadata/package.env")" - printf '%s\n' "$meta" | grep -q '^source_git_commit=' || fail "metadata missing source_git_commit" - printf '%s\n' "$meta" | grep -q '^dirty_tree=' || fail "metadata missing dirty_tree" - printf '%s\n' "$meta" | grep -q '^build_profile=production$' || fail "metadata missing build_profile=production" - printf '%s\n' "$meta" | grep -q '^license=AGPL-3.0-only$' || fail "metadata missing license=AGPL-3.0-only" + grep -q '^source_git_commit=' <<<"$meta" || fail "metadata missing source_git_commit" + grep -q '^dirty_tree=' <<<"$meta" || fail "metadata missing dirty_tree" + grep -q '^build_profile=production$' <<<"$meta" || fail "metadata missing build_profile=production" + grep -q '^license=AGPL-3.0-only$' <<<"$meta" || fail "metadata missing license=AGPL-3.0-only" # Поколение продукта обязано доехать до пакета: install-state строится из # него, а reconfigure/repair по нему отличают v1 от чужой установки. - printf '%s\n' "$meta" | grep -q "^version=${HY2XS_VERSION}$" \ + grep -q "^version=${HY2XS_VERSION}$" <<<"$meta" \ || fail "metadata version disagrees with versions.env HY2XS_VERSION=${HY2XS_VERSION}" - printf '%s\n' "$meta" | grep -q "^release_line=${HY2XS_RELEASE_LINE}$" \ + grep -q "^release_line=${HY2XS_RELEASE_LINE}$" <<<"$meta" \ || fail "metadata release_line disagrees with versions.env HY2XS_RELEASE_LINE=${HY2XS_RELEASE_LINE}" - printf '%s\n' "$meta" | grep -q "^config_schema_version=${HY2XS_CONFIG_SCHEMA_VERSION}$" \ + grep -q "^config_schema_version=${HY2XS_CONFIG_SCHEMA_VERSION}$" <<<"$meta" \ || fail "metadata config_schema_version disagrees with versions.env" - printf '%s\n' "$meta" | grep -q "^target_version=${HY2XS_TARGET_OS_VERSION}$" \ + grep -q "^target_version=${HY2XS_TARGET_OS_VERSION}$" <<<"$meta" \ || fail "metadata target_version disagrees with versions.env" - printf '%s\n' "$meta" | grep -q "^target_arch=${HY2XS_TARGET_ARCH}$" \ + grep -q "^target_arch=${HY2XS_TARGET_ARCH}$" <<<"$meta" \ || fail "metadata target_arch disagrees with versions.env" local packaged_schema diff --git a/tools/build/lib/versions.sh b/tools/build/lib/versions.sh index 8b9aa4b..7fbd427 100644 --- a/tools/build/lib/versions.sh +++ b/tools/build/lib/versions.sh @@ -41,7 +41,7 @@ REQUIRED_CONTRACT_VARS=( validate_sha256_value() { local name="$1" local value="$2" - printf '%s' "$value" | grep -Eq '^[a-f0-9]{64}$' \ + grep -Eq '^[a-f0-9]{64}$' <<<"$value" \ || fail "versions.env: $name must be a 64-char lowercase hex SHA256, got: $value" } @@ -70,11 +70,11 @@ load_versions_contract() { validate_sha256_value BUN_LINUX_X64_BASELINE_SHA256 "$BUN_LINUX_X64_BASELINE_SHA256" validate_sha256_value NODE_LINUX_X64_SHA256 "$NODE_LINUX_X64_SHA256" - printf '%s' "$HY2XS_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' \ + grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' <<<"$HY2XS_VERSION" \ || fail "versions.env: HY2XS_VERSION must be X.Y.Z, got: $HY2XS_VERSION" - printf '%s' "$HY2XS_RELEASE_LINE" | grep -Eq '^[0-9]+$' \ + grep -Eq '^[0-9]+$' <<<"$HY2XS_RELEASE_LINE" \ || fail "versions.env: HY2XS_RELEASE_LINE must be an integer, got: $HY2XS_RELEASE_LINE" - printf '%s' "$HY2XS_CONFIG_SCHEMA_VERSION" | grep -Eq '^[0-9]+$' \ + grep -Eq '^[0-9]+$' <<<"$HY2XS_CONFIG_SCHEMA_VERSION" \ || fail "versions.env: HY2XS_CONFIG_SCHEMA_VERSION must be an integer" # HY2XS_TARGET_OS — это дистрибутив (debian), а GOOS/`target_os` в metadata diff --git a/tools/legacy/purge-v0.sh b/tools/legacy/purge-v0.sh index 5e9062e..4325936 100644 --- a/tools/legacy/purge-v0.sh +++ b/tools/legacy/purge-v0.sh @@ -234,7 +234,9 @@ verify_clean_host() { local unit for unit in "${UNITS[@]}"; do - if systemctl list-unit-files --no-legend "$unit" 2>/dev/null | grep -q .; then + local unit_files + unit_files="$(systemctl list-unit-files --no-legend "$unit" 2>/dev/null)" || unit_files="" + if [ -n "$unit_files" ]; then leftovers+=("systemd unit: $unit") fi done diff --git a/tools/test/e2e-hysteria.sh b/tools/test/e2e-hysteria.sh index 16cdc8e..9d966c5 100755 --- a/tools/test/e2e-hysteria.sh +++ b/tools/test/e2e-hysteria.sh @@ -104,7 +104,12 @@ preflight() { local port for port in "$SERVER_PORT" "$STATS_PORT" "$AUTH_PORT" \ "$ORIGIN_TCP_PORT" "$ORIGIN_UDP_PORT" "$FORWARD_TCP_PORT" "$FORWARD_UDP_PORT"; do - if ss -H -lantu 2>/dev/null | awk '{print $5}' | grep -Eq "[:.]${port}\$"; then + # Вывод собирается ДО поиска: `grep -Eq` обрывает чтение на первом + # совпадении, и под `pipefail` SIGPIPE у продюсера превратил бы найденный + # занятый порт в ненулевой статус, то есть в вывод «порт свободен». + local sockets + sockets="$(ss -H -lantu 2>/dev/null | awk '{print $5}')" || sockets="" + if grep -Eq "[:.]${port}\$" <<<"$sockets"; then die "port $port is already in use" fi done @@ -134,7 +139,9 @@ wait_for_port() { local port="$1" proto_flag="$2" label="$3" attempts="${4:-30}" local i=0 while [ "$i" -lt "$attempts" ]; do - if ss -H "$proto_flag" 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${port}\$"; then + local sockets + sockets="$(ss -H "$proto_flag" 2>/dev/null | awk '{print $4}')" || sockets="" + if grep -Eq "[:.]${port}\$" <<<"$sockets"; then return 0 fi sleep 1 @@ -258,7 +265,9 @@ start_server() { cat "$WORK_DIR/server.log" >&2 || true die "server exited while starting" fi - if ss -H -lun 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${SERVER_PORT}\$"; then + local udp_sockets + udp_sockets="$(ss -H -lun 2>/dev/null | awk '{print $4}')" || udp_sockets="" + if grep -Eq "[:.]${SERVER_PORT}\$" <<<"$udp_sockets"; then return 0 fi sleep 1 @@ -311,11 +320,11 @@ assert_production_share_uri_shape() { *) die "production share URI has an unexpected scheme: $production_uri" ;; esac - printf '%s' "$production_uri" | grep -q "obfs=$obfs_type" \ + grep -q "obfs=$obfs_type" <<<"$production_uri" \ || die "production share URI does not carry obfs=$obfs_type" - printf '%s' "$production_uri" | grep -q 'insecure=0' \ + grep -q 'insecure=0' <<<"$production_uri" \ || die "production share URI must carry insecure=0" - printf '%s' "$production_uri" | grep -q "sni=$SNI_NAME" \ + grep -q "sni=$SNI_NAME" <<<"$production_uri" \ || die "production share URI does not carry the SNI" pass "production share URI keeps certificate verification enabled" @@ -350,7 +359,9 @@ start_client() { cat "$WORK_DIR/client.log" >&2 || true die "client exited while connecting via the generated share URI" fi - if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${FORWARD_TCP_PORT}\$"; then + local tcp_sockets + tcp_sockets="$(ss -H -ltn 2>/dev/null | awk '{print $4}')" || tcp_sockets="" + if grep -Eq "[:.]${FORWARD_TCP_PORT}\$" <<<"$tcp_sockets"; then return 0 fi sleep 1