From f8f81fa87c5e4ab6bedf71c50970c06916fdb812 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Thu, 9 Jul 2026 18:42:50 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat(router):=20=E6=9C=AB=E5=B0=BE?= =?UTF-8?q?=E3=82=BB=E3=82=B0=E3=83=A1=E3=83=B3=E3=83=88=20*name=20?= =?UTF-8?q?=E3=81=AE=20catch-all=20=E3=83=AB=E3=83=BC=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/router.awk | 4 +++ docs/routing.md | 13 +++++++++ tests/unit/run.awk | 4 +++ tests/unit/test_router.awk | 55 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/core/router.awk b/core/router.awk index 62dc6667..94f3e65f 100644 --- a/core/router.awk +++ b/core/router.awk @@ -29,6 +29,10 @@ function _route_add(method, path, handler, pattern, params, parts, n, i, seg) if (substr(seg, 1, 1) == ":") { pattern = pattern "([^/]+)" params = params (params == "" ? "" : ",") substr(seg, 2) + } else if (i == n && substr(seg, 1, 1) == "*" && length(seg) > 1) { + # 末尾セグメント *name -- catch-all。残りパス全体を 1 パラメータで捕捉 + pattern = pattern "(.+)" + params = params (params == "" ? "" : ",") substr(seg, 2) } else { pattern = pattern _route_escape_re(seg) } diff --git a/docs/routing.md b/docs/routing.md index 3ca24f88..f42847f8 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -88,6 +88,19 @@ BEGIN { } ``` +### Catch-all parameters + +A trailing segment of the form `*name` matches the rest of the path +(one or more characters, including `/`): + +```awk +hawk.app.get("/docs/*path", "docs_handler") +# GET /docs/guide/routing -> req["params:path"] = "guide/routing" +``` + +`*name` is only special in the final segment. Anywhere else it is +matched literally. + --- *For the full routing method reference, see [API Reference](api.md).* diff --git a/tests/unit/run.awk b/tests/unit/run.awk index d006a914..f5c5849a 100644 --- a/tests/unit/run.awk +++ b/tests/unit/run.awk @@ -151,6 +151,10 @@ BEGIN { test_router_query_405_includes_accept_query() test_router_query_options() + test_router_catchall_match() + test_router_catchall_with_named_param() + test_router_catchall_nontail_is_literal() + test_ctx_load_copies_req() test_ctx_save_copies_res_back() test_ctx_query_helper() diff --git a/tests/unit/test_router.awk b/tests/unit/test_router.awk index e04f95ed..a7befad7 100644 --- a/tests/unit/test_router.awk +++ b/tests/unit/test_router.awk @@ -170,3 +170,58 @@ function test_router_query_options( req, res, ok, types) { assert_true(index(res["header:allow"], "OPTIONS") > 0, "query: OPTIONS Allow OPTIONS") assert_eq(res["header:accept-query"], "application/json", "query: OPTIONS Accept-Query") } + +function _t_catchall() { ctx::text("rest=" result_val(ctx::param("path"))) } + +function test_router_catchall_match( req, res, ok) { + _router_reset() + GET("/docs/*path", "_t_catchall") + + delete req; req["method"] = "GET"; req["path"] = "/docs/guide/routing" + delete res + ok = router_dispatch(req, res) + assert_eq(ok, 1, "router: catchall matched") + assert_eq(res["body"], "rest=guide/routing", "router: catchall multi-segment param") + + delete req; req["method"] = "GET"; req["path"] = "/docs/a" + delete res + ok = router_dispatch(req, res) + assert_eq(ok, 1, "router: catchall single segment") + assert_eq(res["body"], "rest=a", "router: catchall single segment param") + + # 残りパスが空 (1 文字未満) のときはマッチしない + delete req; req["method"] = "GET"; req["path"] = "/docs/" + delete res + ok = router_dispatch(req, res) + assert_eq(ok, 0, "router: catchall requires at least 1 char") +} + +function _t_catchall_mixed() { ctx::text("id=" result_val(ctx::param("id")) " rest=" result_val(ctx::param("rest"))) } + +function test_router_catchall_with_named_param( req, res, ok) { + _router_reset() + GET("/users/:id/files/*rest", "_t_catchall_mixed") + + delete req; req["method"] = "GET"; req["path"] = "/users/7/files/a/b.txt" + delete res + ok = router_dispatch(req, res) + assert_eq(ok, 1, "router: catchall+named matched") + assert_eq(res["body"], "id=7 rest=a/b.txt", "router: both params extracted") +} + +function _t_catchall_lit() { ctx::text("literal") } + +function test_router_catchall_nontail_is_literal( req, res, ok) { + _router_reset() + GET("/a/*b/c", "_t_catchall_lit") + + delete req; req["method"] = "GET"; req["path"] = "/a/*b/c" + delete res + ok = router_dispatch(req, res) + assert_eq(ok, 1, "router: non-tail *seg matches literally") + + delete req; req["method"] = "GET"; req["path"] = "/a/x/c" + delete res + ok = router_dispatch(req, res) + assert_eq(ok, 0, "router: non-tail *seg is not a wildcard") +} From 7da29dd92e983514c50d9e0dc74679afb2a56334 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Thu, 9 Jul 2026 19:04:18 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat(dsl):=20hawk-libs=20desugar=20?= =?UTF-8?q?=E3=82=92=20@include=20=E5=86=8D=E5=B8=B0=E8=BF=BD=E8=B7=A1=20+?= =?UTF-8?q?=20dist/=20=E3=83=84=E3=83=AA=E3=83=BC=E5=87=BA=E5=8A=9B?= =?UTF-8?q?=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit entry ファイルから @include を再帰的に辿り、dist/ にソースツリーを ミラー出力する multi-file 方式に置き換える。従来は entry 1 ファイルのみ desugar して mktemp に書き出し、@include 先は未desugarのまま gawk の ネイティブ include に委ねていたため、include 先で DSL 構文 (let 等) を 使うと実行時に構文エラーになっていた。 - 訪問済み管理は空白区切り文字列 _seen (bash 3 系互換) - 循環 include は 2 度目以降辿らない、存在しない include は素通し - @include 行は dist/ 接頭の実パスに書き換える (gawk は '/' を含む @include を AWKPATH 探索せずカレントディレクトリ 相対で解決するため) - dsl/desugar.awk 自身の内部 @include (dsl/util.awk 等) も同じ理由で cwd 依存のため、HAWK_LIB に cd してから起動する - hawk-serve/hawk-check/hawk-emit の呼び出し契約 (entry 1 引数、stdout に desugar 済みパス) は変更なし tests/unit/desugar/run.sh を追加し、make test / make ci に配線した。 Co-Authored-By: Claude --- Makefile | 7 +- libexec/hawk-libs | 54 +++++++++++++-- tests/unit/desugar/fixtures/cyc/a.awk | 3 + tests/unit/desugar/fixtures/cyc/b.awk | 2 + tests/unit/desugar/fixtures/fail/bad.awk | 4 ++ tests/unit/desugar/fixtures/fail/main.awk | 2 + tests/unit/desugar/fixtures/missing/main.awk | 2 + tests/unit/desugar/fixtures/proj/app/page.awk | 6 ++ .../desugar/fixtures/proj/app/sub/util.awk | 1 + tests/unit/desugar/fixtures/proj/main.awk | 2 + tests/unit/desugar/fixtures/solo.awk | 1 + tests/unit/desugar/run.sh | 67 +++++++++++++++++++ 12 files changed, 142 insertions(+), 9 deletions(-) create mode 100644 tests/unit/desugar/fixtures/cyc/a.awk create mode 100644 tests/unit/desugar/fixtures/cyc/b.awk create mode 100644 tests/unit/desugar/fixtures/fail/bad.awk create mode 100644 tests/unit/desugar/fixtures/fail/main.awk create mode 100644 tests/unit/desugar/fixtures/missing/main.awk create mode 100644 tests/unit/desugar/fixtures/proj/app/page.awk create mode 100644 tests/unit/desugar/fixtures/proj/app/sub/util.awk create mode 100644 tests/unit/desugar/fixtures/proj/main.awk create mode 100644 tests/unit/desugar/fixtures/solo.awk create mode 100755 tests/unit/desugar/run.sh diff --git a/Makefile b/Makefile index cc3b6d17..427a482b 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # SPDX-License-Identifier: MIT -.PHONY: run dev bench check emit test test-unit test-dsl test-dsl2 test-cli test-e2e lint clean help ci build-libs fetch-libs test-libs libs-clean ci-full +.PHONY: run dev bench check emit test test-unit test-dsl test-dsl2 test-cli test-desugar test-e2e lint clean help ci build-libs fetch-libs test-libs libs-clean ci-full APP ?= app.awk WORKERS ?= 4 @@ -38,7 +38,7 @@ check: ## DSL 型検査のみ (サーバー起動なし) emit: ## desugar 済み AWK を stdout 出力 ./bin/hawk emit $(if $(STRICT),--strict) $(APP) -test: test-unit test-dsl test-dsl2 test-cli test-e2e ## 全テスト +test: test-unit test-dsl test-dsl2 test-cli test-desugar test-e2e ## 全テスト test-unit: ## awk 内 assert @libs_args=""; libs_vars=""; \ @@ -67,6 +67,9 @@ test-dsl2: ## DSL v2 工程別 golden テスト test-cli: ## CLI 単体テスト ./tests/unit/cli/run.sh +test-desugar: ## multi-file desugar テスト + ./tests/unit/desugar/run.sh + test-e2e: ## サーバー起動 + curl ./tests/e2e/run.sh diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 00289907..9c084191 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -52,13 +52,53 @@ case "$subcmd" in echo "[hawk-libs] desugar: file not found: $src" >&2 exit 1 fi - tmp=$(mktemp /tmp/hawk.XXXXXX) - if ! gawk -f "${HAWK_LIB}/dsl/desugar.awk" "$src" > "$tmp"; then - echo "[hawk-libs] desugar failed: $src" >&2 - rm -f "$tmp" - exit 1 - fi - echo "$tmp" + dist="${HAWK_DIST:-dist}" + src_root="$(cd "$(dirname "$src")" && pwd)" + _seen=" " + + # rel: src_root からの相対パス。desugar して dist/rel に出力し、 + # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 + _desugar_one() { + local rel="$1" in out tmp inc incs + case "$_seen" in *" $rel "*) return 0 ;; esac + _seen="$_seen$rel " + in="$src_root/$rel" + out="$dist/$rel" + mkdir -p "$(dirname "$out")" + # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 + # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 + # HAWK_LIB (リポジトリルート) に cd してから実行する。 + if ! ( cd "$HAWK_LIB" && gawk -f dsl/desugar.awk "$in" ) > "$out"; then + echo "[hawk-libs] desugar failed: $in" >&2 + exit 1 + fi + incs="" + while IFS= read -r inc; do + [[ -f "$src_root/$inc" ]] && incs="$incs$inc"$'\n' + done < <(gawk 'match($0, /^[[:space:]]*@include[[:space:]]+"([^"]+)"/, m) { print m[1] }' "$out") || true + if [[ -n "$incs" ]]; then + # gawk は '/' を含む @include パスを AWKPATH 探索しないため、 + # dist 接頭の実パスに書き換える + tmp="$out.tmp" + gawk -v dist="$dist" -v list="$incs" ' + BEGIN { n = split(list, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") L[a[i]] = 1 } + { + if (match($0, /^([[:space:]]*@include[[:space:]]+")([^"]+)(".*)$/, m) && (m[2] in L)) + print m[1] dist "/" m[2] m[3] + else + print + } + ' "$out" > "$tmp" && mv "$tmp" "$out" + while IFS= read -r inc; do + [[ -n "$inc" ]] && _desugar_one "$inc" + done <<< "$incs" + fi + return 0 + } + + entry_rel="$(basename "$src")" + _desugar_one "$entry_rel" + echo "$dist/$entry_rel" ;; *) echo "[hawk-libs] unknown subcommand: $subcmd" >&2 diff --git a/tests/unit/desugar/fixtures/cyc/a.awk b/tests/unit/desugar/fixtures/cyc/a.awk new file mode 100644 index 00000000..4ab2c58c --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc/a.awk @@ -0,0 +1,3 @@ +@include "b.awk" +BEGIN { if (0) cyc_a() } +function cyc_a() { return 1 } diff --git a/tests/unit/desugar/fixtures/cyc/b.awk b/tests/unit/desugar/fixtures/cyc/b.awk new file mode 100644 index 00000000..f1f229d1 --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc/b.awk @@ -0,0 +1,2 @@ +@include "a.awk" +function cyc_b() { return 2 } diff --git a/tests/unit/desugar/fixtures/fail/bad.awk b/tests/unit/desugar/fixtures/fail/bad.awk new file mode 100644 index 00000000..032a60ee --- /dev/null +++ b/tests/unit/desugar/fixtures/fail/bad.awk @@ -0,0 +1,4 @@ +function f( x) { + let x + return x +} diff --git a/tests/unit/desugar/fixtures/fail/main.awk b/tests/unit/desugar/fixtures/fail/main.awk new file mode 100644 index 00000000..cc6e3e7d --- /dev/null +++ b/tests/unit/desugar/fixtures/fail/main.awk @@ -0,0 +1,2 @@ +@include "bad.awk" +BEGIN { print "never" } diff --git a/tests/unit/desugar/fixtures/missing/main.awk b/tests/unit/desugar/fixtures/missing/main.awk new file mode 100644 index 00000000..0ce3c7a4 --- /dev/null +++ b/tests/unit/desugar/fixtures/missing/main.awk @@ -0,0 +1,2 @@ +@include "no/such.awk" +BEGIN { print "ok" } diff --git a/tests/unit/desugar/fixtures/proj/app/page.awk b/tests/unit/desugar/fixtures/proj/app/page.awk new file mode 100644 index 00000000..066c0d54 --- /dev/null +++ b/tests/unit/desugar/fixtures/proj/app/page.awk @@ -0,0 +1,6 @@ +@include "app/sub/util.awk" +@namespace "page" +function greet( msg) { + let msg = "hello from page" + return msg +} diff --git a/tests/unit/desugar/fixtures/proj/app/sub/util.awk b/tests/unit/desugar/fixtures/proj/app/sub/util.awk new file mode 100644 index 00000000..cc437e2a --- /dev/null +++ b/tests/unit/desugar/fixtures/proj/app/sub/util.awk @@ -0,0 +1 @@ +function util_unused(s) { return s } diff --git a/tests/unit/desugar/fixtures/proj/main.awk b/tests/unit/desugar/fixtures/proj/main.awk new file mode 100644 index 00000000..8c8e2bc1 --- /dev/null +++ b/tests/unit/desugar/fixtures/proj/main.awk @@ -0,0 +1,2 @@ +@include "app/page.awk" +BEGIN { print page::greet() } diff --git a/tests/unit/desugar/fixtures/solo.awk b/tests/unit/desugar/fixtures/solo.awk new file mode 100644 index 00000000..752bd80a --- /dev/null +++ b/tests/unit/desugar/fixtures/solo.awk @@ -0,0 +1 @@ +BEGIN { print "solo" } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh new file mode 100755 index 00000000..f34b3bc7 --- /dev/null +++ b/tests/unit/desugar/run.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +set -e +cd "$(dirname "$0")/../../.." + +PASS=0; FAIL=0 +LIBS=./libexec/hawk-libs +LIBS_ABS="$(pwd)/libexec/hawk-libs" +FIX=tests/unit/desugar/fixtures + +ok() { printf " PASS: %s\n" "$1"; PASS=$((PASS+1)); } +ng() { printf " FAIL: %s%s\n" "$1" "${2:+ ($2)}"; FAIL=$((FAIL+1)); } + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# --- 再帰 desugar + @include 書き換え + 実行 --- +dist="$TMP/dist1" +out=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk") +if [[ "$out" == "$dist/main.awk" ]]; then ok "entry_path_echoed"; else ng "entry_path_echoed" "$out"; fi +if [[ -f "$dist/app/page.awk" ]]; then ok "included_file_mirrored"; else ng "included_file_mirrored"; fi +if [[ -f "$dist/app/sub/util.awk" ]]; then ok "nested_include_mirrored"; else ng "nested_include_mirrored"; fi +if grep -qF "@include \"$dist/app/page.awk\"" "$dist/main.awk"; then ok "include_rewritten"; else ng "include_rewritten"; fi +if grep -qF "@include \"$dist/app/sub/util.awk\"" "$dist/app/page.awk"; then ok "nested_include_rewritten"; else ng "nested_include_rewritten"; fi +if ! grep -q "let " "$dist/app/page.awk"; then ok "included_file_desugared"; else ng "included_file_desugared"; fi +run_out=$(gawk -f "$dist/main.awk" /dev/null; then + if [[ -f "$dist/a.awk" && -f "$dist/b.awk" ]]; then ok "cycle_terminates"; else ng "cycle_terminates" "missing outputs"; fi +else + ng "cycle_terminates" "exit != 0" +fi + +# --- 実在しない include は素通り (行もそのまま) --- +dist="$TMP/dist3" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/missing/main.awk" >/dev/null; then + if grep -qF '@include "no/such.awk"' "$dist/main.awk"; then ok "missing_include_passthrough"; else ng "missing_include_passthrough"; fi +else + ng "missing_include_passthrough" "exit != 0" +fi + +# --- include 先の desugar 失敗は exit 1 + ファイル名表示 --- +dist="$TMP/dist4" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/fail/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "include_desugar_failure_exit1"; else ng "include_desugar_failure_exit1" "exit=$st"; fi +if [[ "$err" == *"bad.awk"* ]]; then ok "failure_names_file"; else ng "failure_names_file" "$err"; fi + +# --- HAWK_DIST 未指定なら cwd の dist/ --- +work="$TMP/work"; mkdir -p "$work" +cp "$FIX/solo.awk" "$work/main.awk" +out=$(cd "$work" && "$LIBS_ABS" desugar main.awk) +if [[ "$out" == "dist/main.awk" && -f "$work/dist/main.awk" ]]; then ok "default_dist_dir"; else ng "default_dist_dir" "$out"; fi + +# --- 入力ファイル不在は従来どおり exit 1 --- +set +e +"$LIBS" desugar "$TMP/nope.awk" >/dev/null 2>&1 +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "entry_not_found_exit1"; else ng "entry_not_found_exit1" "exit=$st"; fi + +printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" +[[ $FAIL -eq 0 ]] From f4d4488d248b7cc5b2a150df4264ba7dcd613353 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Thu, 9 Jul 2026 19:11:28 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat(cli):=20desugar=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=89=A9=E3=82=92=20dist/=20=E3=81=AB=E6=B0=B8=E7=B6=9A?= =?UTF-8?q?=E5=8C=96=20(=E4=B8=80=E6=99=82=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E5=89=8A=E9=99=A4=E3=82=92=E5=BB=83=E6=AD=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ libexec/hawk-check | 1 - libexec/hawk-emit | 1 - libexec/hawk-serve | 3 --- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index cacb6a6f..6db12963 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ libtest_gawk_import.a .code-review-graph/ docs/superpowers/ + +dist/ diff --git a/libexec/hawk-check b/libexec/hawk-check index 34f510ff..d311308a 100755 --- a/libexec/hawk-check +++ b/libexec/hawk-check @@ -28,7 +28,6 @@ if [[ -f .env ]]; then fi APP_AWK="$("$LIBS" desugar "$APP")" -trap "rm -f '$APP_AWK'" EXIT if [[ "$STRICT" -eq 1 ]]; then if ! gawk --sandbox -f "${HAWK_LIB}/dsl/adt.awk" -f "${HAWK_LIB}/dsl/type.awk" -f "${HAWK_LIB}/dsl/strict_stubs.awk" -f "$APP_AWK" < /dev/null >/dev/null 2>&1; then diff --git a/libexec/hawk-emit b/libexec/hawk-emit index 0ad6a637..00b513ea 100755 --- a/libexec/hawk-emit +++ b/libexec/hawk-emit @@ -28,7 +28,6 @@ if [[ -f .env ]]; then fi APP_AWK="$("$LIBS" desugar "$APP")" -trap "rm -f '$APP_AWK'" EXIT if [[ "$STRICT" -eq 1 ]]; then if ! gawk --sandbox -f "${HAWK_LIB}/dsl/adt.awk" -f "${HAWK_LIB}/dsl/type.awk" -f "${HAWK_LIB}/dsl/strict_stubs.awk" -f "$APP_AWK" < /dev/null >/dev/null 2>&1; then diff --git a/libexec/hawk-serve b/libexec/hawk-serve index 54590284..8b127512 100755 --- a/libexec/hawk-serve +++ b/libexec/hawk-serve @@ -75,15 +75,12 @@ shutdown() { for pid in "${WORKER_PIDS[@]}"; do wait "$pid" 2>/dev/null || true done - rm -f "$APP_AWK" rm -f "$HAWK_PIDFILE" exit 0 } trap shutdown INT TERM APP_AWK="$("$LIBS" desugar "$APP")" -# shellcheck disable=SC2064 -[[ -z "${HAWK_DEBUG}" ]] && trap "rm -f '$APP_AWK'" EXIT # --workers N かつ supervisor 利用可能な場合は hawk-supervise に委譲する if [[ "$EFFECTIVE_WORKERS" -gt 1 && "$HAS_NET" -eq 1 ]]; then From 73e6a7ad1278a2af4818722a13853c6b2f31107c Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Thu, 9 Jul 2026 19:47:55 +0900 Subject: [PATCH 04/22] =?UTF-8?q?fix(hawk-libs):=20desugar=20=E3=81=AEPR?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @include に '..' が含まれ dist の外へ書き込まれうる問題を修正。 検出時は desugar 失敗として exit 1(P1, codex 指摘) - @include パス書き換えの gawk 呼び出し失敗時にエラーメッセージなしで 黙って続行していた箇所を明示的な exit 1 + ファイル名表示に変更 (coderabbit 指摘) 複数ファイルにまたがる型宣言共有の課題(P2)は #119 に切り出し。 Co-Authored-By: Claude --- libexec/hawk-libs | 15 +++++++++++++-- tests/unit/desugar/fixtures/escape/common.awk | 1 + tests/unit/desugar/fixtures/escape/sub/main.awk | 2 ++ tests/unit/desugar/run.sh | 9 +++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 tests/unit/desugar/fixtures/escape/common.awk create mode 100644 tests/unit/desugar/fixtures/escape/sub/main.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 9c084191..d970fa59 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -61,6 +61,12 @@ case "$subcmd" in _desugar_one() { local rel="$1" in out tmp inc incs case "$_seen" in *" $rel "*) return 0 ;; esac + case "$rel" in + ../*|*/../*) + echo "[hawk-libs] desugar failed: include escapes project root via '..': $rel" >&2 + exit 1 + ;; + esac _seen="$_seen$rel " in="$src_root/$rel" out="$dist/$rel" @@ -80,7 +86,7 @@ case "$subcmd" in # gawk は '/' を含む @include パスを AWKPATH 探索しないため、 # dist 接頭の実パスに書き換える tmp="$out.tmp" - gawk -v dist="$dist" -v list="$incs" ' + if ! gawk -v dist="$dist" -v list="$incs" ' BEGIN { n = split(list, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") L[a[i]] = 1 } { if (match($0, /^([[:space:]]*@include[[:space:]]+")([^"]+)(".*)$/, m) && (m[2] in L)) @@ -88,7 +94,12 @@ case "$subcmd" in else print } - ' "$out" > "$tmp" && mv "$tmp" "$out" + ' "$out" > "$tmp"; then + rm -f "$tmp" + echo "[hawk-libs] desugar failed: include rewrite failed: $out" >&2 + exit 1 + fi + mv "$tmp" "$out" while IFS= read -r inc; do [[ -n "$inc" ]] && _desugar_one "$inc" done <<< "$incs" diff --git a/tests/unit/desugar/fixtures/escape/common.awk b/tests/unit/desugar/fixtures/escape/common.awk new file mode 100644 index 00000000..b348d579 --- /dev/null +++ b/tests/unit/desugar/fixtures/escape/common.awk @@ -0,0 +1 @@ +function common_fn() { return 1 } diff --git a/tests/unit/desugar/fixtures/escape/sub/main.awk b/tests/unit/desugar/fixtures/escape/sub/main.awk new file mode 100644 index 00000000..9763fdf7 --- /dev/null +++ b/tests/unit/desugar/fixtures/escape/sub/main.awk @@ -0,0 +1,2 @@ +@include "../common.awk" +BEGIN { print "never" } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index f34b3bc7..ec24c2c4 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -50,6 +50,15 @@ set -e if [[ "$st" -eq 1 ]]; then ok "include_desugar_failure_exit1"; else ng "include_desugar_failure_exit1" "exit=$st"; fi if [[ "$err" == *"bad.awk"* ]]; then ok "failure_names_file"; else ng "failure_names_file" "$err"; fi +# --- '..' で dist の外に出る include は exit 1(P1: dist 脱出防止) --- +dist="$TMP/dist5" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/escape/sub/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "parent_include_escape_rejected"; else ng "parent_include_escape_rejected" "exit=$st"; fi +if [[ ! -e "$dist/../common.awk" && ! -e "$TMP/common.awk" ]]; then ok "parent_include_no_file_written_outside_dist"; else ng "parent_include_no_file_written_outside_dist" "escaped file found"; fi + # --- HAWK_DIST 未指定なら cwd の dist/ --- work="$TMP/work"; mkdir -p "$work" cp "$FIX/solo.awk" "$work/main.awk" From 57899f875695709fd86ce804b2b5de9bd2f8a7f7 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Thu, 9 Jul 2026 20:46:07 +0900 Subject: [PATCH 05/22] =?UTF-8?q?fix(hawk-libs):=20desugar=20=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=81=8C=20source=20=E3=81=AB=E3=82=A8=E3=82=A4?= =?UTF-8?q?=E3=83=AA=E3=82=A2=E3=82=B9=E3=81=99=E3=82=8B=E5=95=8F=E9=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HAWK_DIST が source ディレクトリと同じ場所を指す場合、 `> "$out"` が gawk 実行前に source ファイルを truncate し、 desugar が空ファイルを exit 0 で返して source を消してしまう 問題を修正(codex 指摘、新規P2)。 dist を絶対パスに正規化した上で in/out のエイリアスを検出し、 desugar 失敗として exit 1 する。 複数アプリでの dist 出力パス衝突(同codex指摘の別課題)は #119 に追記済み。 Co-Authored-By: Claude --- libexec/hawk-libs | 6 ++++++ tests/unit/desugar/fixtures/alias/main.awk | 1 + tests/unit/desugar/run.sh | 13 +++++++++++++ 3 files changed, 20 insertions(+) create mode 100644 tests/unit/desugar/fixtures/alias/main.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index d970fa59..2fcffe1a 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -53,6 +53,8 @@ case "$subcmd" in exit 1 fi dist="${HAWK_DIST:-dist}" + mkdir -p "$dist" + dist_abs="$(cd "$dist" && pwd)" src_root="$(cd "$(dirname "$src")" && pwd)" _seen=" " @@ -70,6 +72,10 @@ case "$subcmd" in _seen="$_seen$rel " in="$src_root/$rel" out="$dist/$rel" + if [[ "$in" == "$dist_abs/$rel" ]]; then + echo "[hawk-libs] desugar failed: output path aliases source (HAWK_DIST resolves onto source tree): $in" >&2 + exit 1 + fi mkdir -p "$(dirname "$out")" # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 diff --git a/tests/unit/desugar/fixtures/alias/main.awk b/tests/unit/desugar/fixtures/alias/main.awk new file mode 100644 index 00000000..aab54fc3 --- /dev/null +++ b/tests/unit/desugar/fixtures/alias/main.awk @@ -0,0 +1 @@ +BEGIN { print "alias-src" } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index ec24c2c4..8e7cff94 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -59,6 +59,19 @@ set -e if [[ "$st" -eq 1 ]]; then ok "parent_include_escape_rejected"; else ng "parent_include_escape_rejected" "exit=$st"; fi if [[ ! -e "$dist/../common.awk" && ! -e "$TMP/common.awk" ]]; then ok "parent_include_no_file_written_outside_dist"; else ng "parent_include_no_file_written_outside_dist" "escaped file found"; fi +# --- HAWK_DIST が source と同じ場所を指すと in/out がエイリアスする問題 --- +alias_dir="$TMP/alias" +mkdir -p "$alias_dir" +cp "$FIX/alias/main.awk" "$alias_dir/main.awk" +before_hash=$(cksum < "$alias_dir/main.awk") +set +e +err=$(HAWK_DIST="$alias_dir" "$LIBS" desugar "$alias_dir/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "alias_dist_rejected"; else ng "alias_dist_rejected" "exit=$st"; fi +after_hash=$(cksum < "$alias_dir/main.awk") +if [[ "$before_hash" == "$after_hash" ]]; then ok "alias_dist_source_untouched"; else ng "alias_dist_source_untouched" "source file was modified"; fi + # --- HAWK_DIST 未指定なら cwd の dist/ --- work="$TMP/work"; mkdir -p "$work" cp "$FIX/solo.awk" "$work/main.awk" From e197ba41f8f3fbeec35c5f58db9992066e3467f7 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 09:39:33 +0900 Subject: [PATCH 06/22] =?UTF-8?q?fix(hawk-libs):=20desugar=20=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=81=AE=20scratch/mv=20=E5=8C=96=E3=81=A8=E3=83=91?= =?UTF-8?q?=E3=82=B9=E7=89=A9=E7=90=86=E6=AD=A3=E8=A6=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃3件(PR #118)への対応: - source エイリアス検査を pwd -P による物理パス比較に変更。 HAWK_DIST が symlink 経由で source ディレクトリを指すケースの すり抜けを防ぐ - 書き換え scratch を固定名 ($out.tmp) から mktemp に変更。 include 対象自体が .tmp で終わる名前のとき既出力の成果物を 破壊する問題を解消 - desugar 出力を scratch に書き、書き換え完了後に mv で アトミックに配置。稼働中の hawk-serve worker が truncate 途中の 成果物を再ロードする窓を除去 Co-Authored-By: Claude --- libexec/hawk-libs | 30 ++++++++++++++------ tests/unit/desugar/fixtures/tmpcol/a.awk | 2 ++ tests/unit/desugar/fixtures/tmpcol/a.awk.tmp | 1 + tests/unit/desugar/fixtures/tmpcol/b.awk | 1 + tests/unit/desugar/fixtures/tmpcol/main.awk | 3 ++ tests/unit/desugar/run.sh | 30 ++++++++++++++++++++ 6 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 tests/unit/desugar/fixtures/tmpcol/a.awk create mode 100644 tests/unit/desugar/fixtures/tmpcol/a.awk.tmp create mode 100644 tests/unit/desugar/fixtures/tmpcol/b.awk create mode 100644 tests/unit/desugar/fixtures/tmpcol/main.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 2fcffe1a..cf266a95 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -54,8 +54,10 @@ case "$subcmd" in fi dist="${HAWK_DIST:-dist}" mkdir -p "$dist" - dist_abs="$(cd "$dist" && pwd)" - src_root="$(cd "$(dirname "$src")" && pwd)" + # pwd -P で symlink を解決した物理パスに正規化する + # (論理パス比較だと symlink 経由の source エイリアスを見逃す) + dist_abs="$(cd "$dist" && pwd -P)" + src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " # rel: src_root からの相対パス。desugar して dist/rel に出力し、 @@ -77,21 +79,27 @@ case "$subcmd" in exit 1 fi mkdir -p "$(dirname "$out")" + # 稼働中の hawk-serve が dist の成果物を再ロードしうるため、 + # scratch (mktemp) 上で desugar と書き換えを済ませてから + # mv でアトミックに配置する。scratch を mktemp にすることで + # 固定名 ($out.tmp) が実在する成果物と衝突する問題も避ける。 + tmp="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 # HAWK_LIB (リポジトリルート) に cd してから実行する。 - if ! ( cd "$HAWK_LIB" && gawk -f dsl/desugar.awk "$in" ) > "$out"; then + if ! ( cd "$HAWK_LIB" && gawk -f dsl/desugar.awk "$in" ) > "$tmp"; then + rm -f "$tmp" echo "[hawk-libs] desugar failed: $in" >&2 exit 1 fi incs="" while IFS= read -r inc; do [[ -f "$src_root/$inc" ]] && incs="$incs$inc"$'\n' - done < <(gawk 'match($0, /^[[:space:]]*@include[[:space:]]+"([^"]+)"/, m) { print m[1] }' "$out") || true + done < <(gawk 'match($0, /^[[:space:]]*@include[[:space:]]+"([^"]+)"/, m) { print m[1] }' "$tmp") || true if [[ -n "$incs" ]]; then # gawk は '/' を含む @include パスを AWKPATH 探索しないため、 # dist 接頭の実パスに書き換える - tmp="$out.tmp" + tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" if ! gawk -v dist="$dist" -v list="$incs" ' BEGIN { n = split(list, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") L[a[i]] = 1 } { @@ -100,12 +108,16 @@ case "$subcmd" in else print } - ' "$out" > "$tmp"; then - rm -f "$tmp" - echo "[hawk-libs] desugar failed: include rewrite failed: $out" >&2 + ' "$tmp" > "$tmp2"; then + rm -f "$tmp" "$tmp2" + echo "[hawk-libs] desugar failed: include rewrite failed: $in" >&2 exit 1 fi - mv "$tmp" "$out" + mv "$tmp2" "$tmp" + fi + chmod 644 "$tmp" + mv "$tmp" "$out" + if [[ -n "$incs" ]]; then while IFS= read -r inc; do [[ -n "$inc" ]] && _desugar_one "$inc" done <<< "$incs" diff --git a/tests/unit/desugar/fixtures/tmpcol/a.awk b/tests/unit/desugar/fixtures/tmpcol/a.awk new file mode 100644 index 00000000..f2d3ba17 --- /dev/null +++ b/tests/unit/desugar/fixtures/tmpcol/a.awk @@ -0,0 +1,2 @@ +@include "b.awk" +function tc_a() { return 2 } diff --git a/tests/unit/desugar/fixtures/tmpcol/a.awk.tmp b/tests/unit/desugar/fixtures/tmpcol/a.awk.tmp new file mode 100644 index 00000000..324e659f --- /dev/null +++ b/tests/unit/desugar/fixtures/tmpcol/a.awk.tmp @@ -0,0 +1 @@ +function tc_tmp() { return 1 } diff --git a/tests/unit/desugar/fixtures/tmpcol/b.awk b/tests/unit/desugar/fixtures/tmpcol/b.awk new file mode 100644 index 00000000..db5b7e93 --- /dev/null +++ b/tests/unit/desugar/fixtures/tmpcol/b.awk @@ -0,0 +1 @@ +function tc_b() { return 3 } diff --git a/tests/unit/desugar/fixtures/tmpcol/main.awk b/tests/unit/desugar/fixtures/tmpcol/main.awk new file mode 100644 index 00000000..4dc0eb0e --- /dev/null +++ b/tests/unit/desugar/fixtures/tmpcol/main.awk @@ -0,0 +1,3 @@ +@include "a.awk.tmp" +@include "a.awk" +BEGIN { print "tmpcol" } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 8e7cff94..fd77d07b 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -72,6 +72,36 @@ if [[ "$st" -eq 1 ]]; then ok "alias_dist_rejected"; else ng "alias_dist_rejecte after_hash=$(cksum < "$alias_dir/main.awk") if [[ "$before_hash" == "$after_hash" ]]; then ok "alias_dist_source_untouched"; else ng "alias_dist_source_untouched" "source file was modified"; fi +# --- symlink 経由の source エイリアスも検出する --- +alias2_dir="$TMP/alias2" +mkdir -p "$alias2_dir" +cp "$FIX/alias/main.awk" "$alias2_dir/main.awk" +ln -s "$alias2_dir" "$TMP/alias2-link" +before_hash=$(cksum < "$alias2_dir/main.awk") +set +e +err=$(HAWK_DIST="$TMP/alias2-link" "$LIBS" desugar "$alias2_dir/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "alias_dist_symlink_rejected"; else ng "alias_dist_symlink_rejected" "exit=$st"; fi +after_hash=$(cksum < "$alias2_dir/main.awk") +if [[ "$before_hash" == "$after_hash" ]]; then ok "alias_dist_symlink_source_untouched"; else ng "alias_dist_symlink_source_untouched" "source file was modified"; fi + +# --- include 名が .tmp で終わっても scratch と衝突しない --- +dist="$TMP/dist6" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/tmpcol/main.awk" >/dev/null; then + if [[ -f "$dist/a.awk.tmp" ]] && grep -q "tc_tmp" "$dist/a.awk.tmp"; then + ok "tmp_suffix_include_survives_rewrite" + else + ng "tmp_suffix_include_survives_rewrite" "dist/a.awk.tmp missing or clobbered" + fi +else + ng "tmp_suffix_include_survives_rewrite" "exit != 0" +fi + +# --- scratch ファイルが dist に残らない --- +leftovers=$(find "$TMP/dist1" "$TMP/dist6" -name ".hawk-desugar.*" 2>/dev/null | wc -l) +if [[ "$leftovers" -eq 0 ]]; then ok "no_scratch_leftovers"; else ng "no_scratch_leftovers" "$leftovers scratch files remain"; fi + # --- HAWK_DIST 未指定なら cwd の dist/ --- work="$TMP/work"; mkdir -p "$work" cp "$FIX/solo.awk" "$work/main.awk" From 9ea4a00b124482fb1520633674b4573ec85e4798 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 10:01:36 +0900 Subject: [PATCH 07/22] =?UTF-8?q?fix(hawk-libs):=20desugar=20=E3=81=AE=20p?= =?UTF-8?q?ublish=20=E9=A0=86=E5=BA=8F=E3=83=BBentry=20=E5=BE=AA=E7=92=B0?= =?UTF-8?q?=E3=83=BBsymlink=20source=20=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃3件(PR #118)への対応: - 子 include を先に desugar し、全て成功してから親を mv で publish する post-order に変更。子の失敗時に「存在しない子を 参照する新しい親」が dist に残る問題を解消。途中失敗時の scratch は EXIT trap で掃除 - entry に戻る循環 include を desugar 時点で exit 1 で拒否。 gawk は program file の再 include を実行時 fatal にするため、 check/desugar 成功 + serve 失敗という不整合を防ぐ (entry を経由しない循環は gawk が重複 include をスキップする ため従来どおり許容、cyc2 フィクスチャで検証) - source エイリアス検査を -ef (device/inode 比較) に変更。 source 側が symlink で dist に解決するケースも検出 Co-Authored-By: Claude --- libexec/hawk-libs | 30 ++++++++++++---- tests/unit/desugar/fixtures/cyc2/main.awk | 2 ++ tests/unit/desugar/fixtures/cyc2/x.awk | 2 ++ tests/unit/desugar/fixtures/cyc2/y.awk | 2 ++ .../desugar/fixtures/symsrc/srcdir/main.awk | 1 + tests/unit/desugar/run.sh | 34 +++++++++++++++++-- 6 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 tests/unit/desugar/fixtures/cyc2/main.awk create mode 100644 tests/unit/desugar/fixtures/cyc2/x.awk create mode 100644 tests/unit/desugar/fixtures/cyc2/y.awk create mode 100644 tests/unit/desugar/fixtures/symsrc/srcdir/main.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index cf266a95..40fc7bc6 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -54,11 +54,9 @@ case "$subcmd" in fi dist="${HAWK_DIST:-dist}" mkdir -p "$dist" - # pwd -P で symlink を解決した物理パスに正規化する - # (論理パス比較だと symlink 経由の source エイリアスを見逃す) - dist_abs="$(cd "$dist" && pwd -P)" src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " + _scratches="" # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 @@ -74,7 +72,9 @@ case "$subcmd" in _seen="$_seen$rel " in="$src_root/$rel" out="$dist/$rel" - if [[ "$in" == "$dist_abs/$rel" ]]; then + # -ef は device/inode 比較。symlink 経由 (HAWK_DIST・source どちら側でも) + # の source エイリアスを文字列比較より確実に検出する + if [[ -e "$out" && "$in" -ef "$out" ]]; then echo "[hawk-libs] desugar failed: output path aliases source (HAWK_DIST resolves onto source tree): $in" >&2 exit 1 fi @@ -84,6 +84,10 @@ case "$subcmd" in # mv でアトミックに配置する。scratch を mktemp にすることで # 固定名 ($out.tmp) が実在する成果物と衝突する問題も避ける。 tmp="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" + # 途中失敗時の scratch 残留を防ぐ (mv 済みの名前への rm -f は no-op) + _scratches="$_scratches $tmp" + # shellcheck disable=SC2064 + trap "rm -f$_scratches" EXIT # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 # HAWK_LIB (リポジトリルート) に cd してから実行する。 @@ -94,12 +98,24 @@ case "$subcmd" in fi incs="" while IFS= read -r inc; do + # entry への back-edge は gawk が実行時に + # "cannot include `X' and use it as a program file" で fatal になるため + # desugar 時点で拒否する (entry を経由しない循環は gawk が重複 include を + # スキップするので許容) + if [[ "$inc" == "$entry_rel" ]]; then + rm -f "$tmp" + echo "[hawk-libs] desugar failed: include cycle back to entry: $rel includes $inc" >&2 + exit 1 + fi [[ -f "$src_root/$inc" ]] && incs="$incs$inc"$'\n' done < <(gawk 'match($0, /^[[:space:]]*@include[[:space:]]+"([^"]+)"/, m) { print m[1] }' "$tmp") || true if [[ -n "$incs" ]]; then # gawk は '/' を含む @include パスを AWKPATH 探索しないため、 # dist 接頭の実パスに書き換える tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" + _scratches="$_scratches $tmp2" + # shellcheck disable=SC2064 + trap "rm -f$_scratches" EXIT if ! gawk -v dist="$dist" -v list="$incs" ' BEGIN { n = split(list, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") L[a[i]] = 1 } { @@ -115,13 +131,15 @@ case "$subcmd" in fi mv "$tmp2" "$tmp" fi - chmod 644 "$tmp" - mv "$tmp" "$out" + # 子を先に desugar し、全て成功してから親を publish する (post-order)。 + # 子の失敗時に「存在しない子を参照する新しい親」が dist に残るのを防ぐ if [[ -n "$incs" ]]; then while IFS= read -r inc; do [[ -n "$inc" ]] && _desugar_one "$inc" done <<< "$incs" fi + chmod 644 "$tmp" + mv "$tmp" "$out" return 0 } diff --git a/tests/unit/desugar/fixtures/cyc2/main.awk b/tests/unit/desugar/fixtures/cyc2/main.awk new file mode 100644 index 00000000..e76c2e22 --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc2/main.awk @@ -0,0 +1,2 @@ +@include "x.awk" +BEGIN { print "cyc2" } diff --git a/tests/unit/desugar/fixtures/cyc2/x.awk b/tests/unit/desugar/fixtures/cyc2/x.awk new file mode 100644 index 00000000..8bab8268 --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc2/x.awk @@ -0,0 +1,2 @@ +@include "y.awk" +function fx() { return 1 } diff --git a/tests/unit/desugar/fixtures/cyc2/y.awk b/tests/unit/desugar/fixtures/cyc2/y.awk new file mode 100644 index 00000000..87f01fc4 --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc2/y.awk @@ -0,0 +1,2 @@ +@include "x.awk" +function fy() { return 2 } diff --git a/tests/unit/desugar/fixtures/symsrc/srcdir/main.awk b/tests/unit/desugar/fixtures/symsrc/srcdir/main.awk new file mode 100644 index 00000000..2a150708 --- /dev/null +++ b/tests/unit/desugar/fixtures/symsrc/srcdir/main.awk @@ -0,0 +1 @@ +BEGIN { print "symsrc" } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index fd77d07b..5c77ee20 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -25,10 +25,21 @@ if ! grep -q "let " "$dist/app/page.awk"; then ok "included_file_desugared"; els run_out=$(gawk -f "$dist/main.awk" /dev/null; then - if [[ -f "$dist/a.awk" && -f "$dist/b.awk" ]]; then ok "cycle_terminates"; else ng "cycle_terminates" "missing outputs"; fi +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/cyc/a.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "entry_cycle_rejected"; else ng "entry_cycle_rejected" "exit=$st"; fi +if [[ "$err" == *"cycle back to entry"* ]]; then ok "entry_cycle_names_cause"; else ng "entry_cycle_names_cause" "$err"; fi + +# --- entry を経由しない循環は 1 回ずつで停止し、実行も通る --- +dist="$TMP/dist2b" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/cyc2/main.awk" >/dev/null; then + if [[ -f "$dist/x.awk" && -f "$dist/y.awk" ]]; then ok "cycle_terminates"; else ng "cycle_terminates" "missing outputs"; fi + run_out=$(gawk -f "$dist/main.awk" /dev/null | wc -l) +if [[ "$leftovers" -eq 0 ]]; then ok "no_scratch_leftovers_on_failure"; else ng "no_scratch_leftovers_on_failure" "$leftovers scratch files remain"; fi # --- '..' で dist の外に出る include は exit 1(P1: dist 脱出防止) --- dist="$TMP/dist5" @@ -86,6 +100,20 @@ if [[ "$st" -eq 1 ]]; then ok "alias_dist_symlink_rejected"; else ng "alias_dist after_hash=$(cksum < "$alias2_dir/main.awk") if [[ "$before_hash" == "$after_hash" ]]; then ok "alias_dist_symlink_source_untouched"; else ng "alias_dist_symlink_source_untouched" "source file was modified"; fi +# --- source 側が symlink で dist に解決するケースも検出する --- +symsrc="$TMP/symsrc" +mkdir -p "$symsrc/srcdir" "$symsrc/linkdir" +cp "$FIX/symsrc/srcdir/main.awk" "$symsrc/srcdir/main.awk" +ln -s ../srcdir/main.awk "$symsrc/linkdir/main.awk" +before_hash=$(cksum < "$symsrc/srcdir/main.awk") +set +e +err=$(HAWK_DIST="$symsrc/srcdir" "$LIBS" desugar "$symsrc/linkdir/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "symlinked_source_alias_rejected"; else ng "symlinked_source_alias_rejected" "exit=$st"; fi +after_hash=$(cksum < "$symsrc/srcdir/main.awk") +if [[ "$before_hash" == "$after_hash" ]]; then ok "symlinked_source_untouched"; else ng "symlinked_source_untouched" "source file was modified"; fi + # --- include 名が .tmp で終わっても scratch と衝突しない --- dist="$TMP/dist6" if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/tmpcol/main.awk" >/dev/null; then From d8dedff43496e33d1dc7b889cccaf922589a8f52 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 10:20:48 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix(hawk-libs):=20@include=20=E3=83=91?= =?UTF-8?q?=E3=82=B9=E6=AD=A3=E8=A6=8F=E5=8C=96=E3=81=A8=20closure=20?= =?UTF-8?q?=E4=B8=80=E6=8B=AC=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃2件(PR #118)への対応: - desugar 直後に @include パスの ./ を正規化するパスを追加。 "./a.awk" 形式の entry back-edge が生テキスト比較を すり抜けて実行時 fatal になる問題を解消。_seen 重複排除・ dist 書き換え対応表も同一視できるようになる - publish を「closure 全体の成功後に一括 mv」の2フェーズに変更。 兄弟 include の失敗時に新旧成果物が dist に混在する窓を除去 (publish 順は深い子から、entry が最後) Co-Authored-By: Claude --- libexec/hawk-libs | 36 ++++++++++++++++--- tests/unit/desugar/fixtures/cyc3/a.awk | 2 ++ tests/unit/desugar/fixtures/cyc3/b.awk | 2 ++ tests/unit/desugar/fixtures/dotslash/main.awk | 2 ++ tests/unit/desugar/fixtures/dotslash/x.awk | 1 + tests/unit/desugar/fixtures/sib/a_good.awk | 1 + tests/unit/desugar/fixtures/sib/main.awk | 3 ++ tests/unit/desugar/fixtures/sib/z_bad.awk | 4 +++ tests/unit/desugar/run.sh | 30 ++++++++++++++++ 9 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 tests/unit/desugar/fixtures/cyc3/a.awk create mode 100644 tests/unit/desugar/fixtures/cyc3/b.awk create mode 100644 tests/unit/desugar/fixtures/dotslash/main.awk create mode 100644 tests/unit/desugar/fixtures/dotslash/x.awk create mode 100644 tests/unit/desugar/fixtures/sib/a_good.awk create mode 100644 tests/unit/desugar/fixtures/sib/main.awk create mode 100644 tests/unit/desugar/fixtures/sib/z_bad.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 40fc7bc6..35f953e7 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -57,6 +57,7 @@ case "$subcmd" in src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " _scratches="" + _publish="" # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 @@ -96,6 +97,28 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: $in" >&2 exit 1 fi + # @include パスの ./ を正規化する。生テキスト比較の + # entry 循環検査・_seen 重複排除・書き換えの対応表が + # "./a.awk" と "a.awk" を同一視できるようにするため + tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" + _scratches="$_scratches $tmp2" + # shellcheck disable=SC2064 + trap "rm -f$_scratches" EXIT + if ! gawk ' + { + if (match($0, /^([[:space:]]*@include[[:space:]]+")([^"]+)(".*)$/, m)) { + p = m[2] + sub(/^(\.\/)+/, "", p) + while (gsub(/\/\.\//, "/", p)) {} + print m[1] p m[3] + } else print + } + ' "$tmp" > "$tmp2"; then + rm -f "$tmp" "$tmp2" + echo "[hawk-libs] desugar failed: include normalize failed: $in" >&2 + exit 1 + fi + mv "$tmp2" "$tmp" incs="" while IFS= read -r inc; do # entry への back-edge は gawk が実行時に @@ -131,20 +154,25 @@ case "$subcmd" in fi mv "$tmp2" "$tmp" fi - # 子を先に desugar し、全て成功してから親を publish する (post-order)。 - # 子の失敗時に「存在しない子を参照する新しい親」が dist に残るのを防ぐ + # 子を先に desugar し、closure 全体が成功するまで publish しない。 + # 途中失敗時に新旧の成果物が dist に混在するのを防ぐ if [[ -n "$incs" ]]; then while IFS= read -r inc; do [[ -n "$inc" ]] && _desugar_one "$inc" done <<< "$incs" fi - chmod 644 "$tmp" - mv "$tmp" "$out" + _publish="$_publish$tmp $out"$'\n' return 0 } entry_rel="$(basename "$src")" _desugar_one "$entry_rel" + # closure 全体が成功してから一括 publish (深い子から、entry が最後) + while read -r t o; do + [[ -n "$t" ]] || continue + chmod 644 "$t" + mv "$t" "$o" + done <<< "$_publish" echo "$dist/$entry_rel" ;; *) diff --git a/tests/unit/desugar/fixtures/cyc3/a.awk b/tests/unit/desugar/fixtures/cyc3/a.awk new file mode 100644 index 00000000..ec2254a5 --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc3/a.awk @@ -0,0 +1,2 @@ +@include "b.awk" +BEGIN { print "cyc3" } diff --git a/tests/unit/desugar/fixtures/cyc3/b.awk b/tests/unit/desugar/fixtures/cyc3/b.awk new file mode 100644 index 00000000..f7efe08b --- /dev/null +++ b/tests/unit/desugar/fixtures/cyc3/b.awk @@ -0,0 +1,2 @@ +@include "./a.awk" +function c3b() { return 1 } diff --git a/tests/unit/desugar/fixtures/dotslash/main.awk b/tests/unit/desugar/fixtures/dotslash/main.awk new file mode 100644 index 00000000..eaf8197f --- /dev/null +++ b/tests/unit/desugar/fixtures/dotslash/main.awk @@ -0,0 +1,2 @@ +@include "./x.awk" +BEGIN { print ds_x() } diff --git a/tests/unit/desugar/fixtures/dotslash/x.awk b/tests/unit/desugar/fixtures/dotslash/x.awk new file mode 100644 index 00000000..8d7c5c39 --- /dev/null +++ b/tests/unit/desugar/fixtures/dotslash/x.awk @@ -0,0 +1 @@ +function ds_x() { return "dotslash-x" } diff --git a/tests/unit/desugar/fixtures/sib/a_good.awk b/tests/unit/desugar/fixtures/sib/a_good.awk new file mode 100644 index 00000000..e42d2217 --- /dev/null +++ b/tests/unit/desugar/fixtures/sib/a_good.awk @@ -0,0 +1 @@ +function sib_good() { return 1 } diff --git a/tests/unit/desugar/fixtures/sib/main.awk b/tests/unit/desugar/fixtures/sib/main.awk new file mode 100644 index 00000000..89a0afab --- /dev/null +++ b/tests/unit/desugar/fixtures/sib/main.awk @@ -0,0 +1,3 @@ +@include "a_good.awk" +@include "z_bad.awk" +BEGIN { print "never" } diff --git a/tests/unit/desugar/fixtures/sib/z_bad.awk b/tests/unit/desugar/fixtures/sib/z_bad.awk new file mode 100644 index 00000000..d09be843 --- /dev/null +++ b/tests/unit/desugar/fixtures/sib/z_bad.awk @@ -0,0 +1,4 @@ +function sib_bad( x) { + let x + return x +} diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 5c77ee20..0022f3f9 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -34,6 +34,24 @@ set -e if [[ "$st" -eq 1 ]]; then ok "entry_cycle_rejected"; else ng "entry_cycle_rejected" "exit=$st"; fi if [[ "$err" == *"cycle back to entry"* ]]; then ok "entry_cycle_names_cause"; else ng "entry_cycle_names_cause" "$err"; fi +# --- "./entry" 形式の back-edge も正規化して検出する --- +dist="$TMP/dist2c" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/cyc3/a.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"cycle back to entry"* ]]; then ok "entry_cycle_dotslash_rejected"; else ng "entry_cycle_dotslash_rejected" "exit=$st: $err"; fi + +# --- "./x.awk" 形式の include は正規化されて dist に書き換わる --- +dist="$TMP/distds" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/dotslash/main.awk" >/dev/null; then + if grep -qF "@include \"$dist/x.awk\"" "$dist/main.awk"; then ok "dotslash_include_normalized"; else ng "dotslash_include_normalized"; fi + run_out=$(gawk -f "$dist/main.awk" /dev/null; then @@ -64,6 +82,18 @@ if [[ ! -e "$dist/main.awk" ]]; then ok "parent_not_published_on_child_failure"; leftovers=$(find "$dist" -name ".hawk-desugar.*" 2>/dev/null | wc -l) if [[ "$leftovers" -eq 0 ]]; then ok "no_scratch_leftovers_on_failure"; else ng "no_scratch_leftovers_on_failure" "$leftovers scratch files remain"; fi +# --- 兄弟 include の失敗時は closure 全体を publish しない --- +dist="$TMP/dist4b" +set +e +HAWK_DIST="$dist" "$LIBS" desugar "$FIX/sib/main.awk" >/dev/null 2>&1 +st=$? +set -e +if [[ "$st" -eq 1 && ! -e "$dist/a_good.awk" && ! -e "$dist/main.awk" ]]; then + ok "sibling_failure_publishes_nothing" +else + ng "sibling_failure_publishes_nothing" "exit=$st, a_good=$([[ -e "$dist/a_good.awk" ]] && echo yes || echo no)" +fi + # --- '..' で dist の外に出る include は exit 1(P1: dist 脱出防止) --- dist="$TMP/dist5" set +e From fe177c0cf271df2705f9dee1f2f2bf4b35f5baa6 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 16:15:56 +0900 Subject: [PATCH 09/22] =?UTF-8?q?fix(hawk-libs):=20desugar=20=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E5=85=88=E3=81=AE=E4=BF=9D=E8=AD=B7=E3=81=A8=20includ?= =?UTF-8?q?e=20=E8=A7=A3=E6=B1=BA=E3=81=AE=E5=8E=B3=E5=AF=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃4件(PR #118)への対応: - 出力先が既存ディレクトリの場合は exit 1 (mv がディレクトリ内へ移動して exit 0 のまま壊れるのを防止) - dist ルートに .hawk-dist マーカーを導入。マーカーのない ディレクトリ内の既存ファイルは source とみなし上書き拒否 (HAWK_DIST が source 木の中の別ファイルを指すケースは -ef では検出できないため) - ./ 正規化を src_root 相対で実在するファイルに限定。 実在しない "./x.awk" を "x.awk" に書き換えると素通り後に AWKPATH で無関係のファイルを拾う挙動変化が起きるため - entry 循環検査に -ef を追加し、symlink/hardlink 経由の entry エイリアス include も検出 Co-Authored-By: Claude --- libexec/hawk-libs | 29 ++++++++++-- tests/unit/desugar/fixtures/dotmiss/main.awk | 2 + tests/unit/desugar/fixtures/symcyc/a.awk | 2 + tests/unit/desugar/fixtures/symcyc/b.awk | 2 + tests/unit/desugar/run.sh | 48 ++++++++++++++++++++ 5 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 tests/unit/desugar/fixtures/dotmiss/main.awk create mode 100644 tests/unit/desugar/fixtures/symcyc/a.awk create mode 100644 tests/unit/desugar/fixtures/symcyc/b.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 35f953e7..ebbde15e 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -79,6 +79,18 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: output path aliases source (HAWK_DIST resolves onto source tree): $in" >&2 exit 1 fi + # 出力先がディレクトリだと mv がその中に移動してしまい exit 0 のまま壊れる + if [[ -d "$out" ]]; then + echo "[hawk-libs] desugar failed: output path is a directory: $out" >&2 + exit 1 + fi + # 過去の desugar が作った dist であることを .hawk-dist マーカーで確認する。 + # マーカーのないディレクトリ内の既存ファイルは source とみなし上書きしない + # (HAWK_DIST が source 木の中を指すと -ef では検出できない別ファイルを潰すため) + if [[ -e "$out" && ! -e "$dist/.hawk-dist" ]]; then + echo "[hawk-libs] desugar failed: refusing to overwrite existing file in non-dist directory: $out (remove it or the whole directory if it is a stale dist)" >&2 + exit 1 + fi mkdir -p "$(dirname "$out")" # 稼働中の hawk-serve が dist の成果物を再ロードしうるため、 # scratch (mktemp) 上で desugar と書き換えを済ませてから @@ -99,17 +111,26 @@ case "$subcmd" in fi # @include パスの ./ を正規化する。生テキスト比較の # entry 循環検査・_seen 重複排除・書き換えの対応表が - # "./a.awk" と "a.awk" を同一視できるようにするため + # "./a.awk" と "a.awk" を同一視できるようにするため。 + # 正規化は src_root 相対で実在するファイルに限る + # ("./x.awk" はローカル解決のみだが "x.awk" は AWKPATH に落ちるため、 + # 実在しないパスを書き換えると挙動が変わってしまう) tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" _scratches="$_scratches $tmp2" # shellcheck disable=SC2064 trap "rm -f$_scratches" EXIT - if ! gawk ' + if ! gawk -v root="$src_root" ' + function exists(f, t, r) { + r = (getline t < f) + if (r >= 0) { close(f); return 1 } + return 0 + } { if (match($0, /^([[:space:]]*@include[[:space:]]+")([^"]+)(".*)$/, m)) { p = m[2] sub(/^(\.\/)+/, "", p) while (gsub(/\/\.\//, "/", p)) {} + if (p != m[2] && !exists(root "/" p)) p = m[2] print m[1] p m[3] } else print } @@ -125,7 +146,8 @@ case "$subcmd" in # "cannot include `X' and use it as a program file" で fatal になるため # desugar 時点で拒否する (entry を経由しない循環は gawk が重複 include を # スキップするので許容) - if [[ "$inc" == "$entry_rel" ]]; then + # -ef で symlink/hardlink 経由の entry エイリアスも検出する + if [[ "$inc" == "$entry_rel" || "$src_root/$inc" -ef "$src_root/$entry_rel" ]]; then rm -f "$tmp" echo "[hawk-libs] desugar failed: include cycle back to entry: $rel includes $inc" >&2 exit 1 @@ -173,6 +195,7 @@ case "$subcmd" in chmod 644 "$t" mv "$t" "$o" done <<< "$_publish" + : > "$dist/.hawk-dist" echo "$dist/$entry_rel" ;; *) diff --git a/tests/unit/desugar/fixtures/dotmiss/main.awk b/tests/unit/desugar/fixtures/dotmiss/main.awk new file mode 100644 index 00000000..d63a1416 --- /dev/null +++ b/tests/unit/desugar/fixtures/dotmiss/main.awk @@ -0,0 +1,2 @@ +@include "./nosuch.awk" +BEGIN { print "dotmiss" } diff --git a/tests/unit/desugar/fixtures/symcyc/a.awk b/tests/unit/desugar/fixtures/symcyc/a.awk new file mode 100644 index 00000000..041fe2d1 --- /dev/null +++ b/tests/unit/desugar/fixtures/symcyc/a.awk @@ -0,0 +1,2 @@ +@include "b.awk" +BEGIN { print "symcyc" } diff --git a/tests/unit/desugar/fixtures/symcyc/b.awk b/tests/unit/desugar/fixtures/symcyc/b.awk new file mode 100644 index 00000000..4eb9f533 --- /dev/null +++ b/tests/unit/desugar/fixtures/symcyc/b.awk @@ -0,0 +1,2 @@ +@include "alias.awk" +function sc_b() { return 1 } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 0022f3f9..ba54275c 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -42,6 +42,26 @@ st=$? set -e if [[ "$st" -eq 1 && "$err" == *"cycle back to entry"* ]]; then ok "entry_cycle_dotslash_rejected"; else ng "entry_cycle_dotslash_rejected" "exit=$st: $err"; fi +# --- symlink 経由の entry 循環も検出する --- +symcyc="$TMP/symcyc" +mkdir -p "$symcyc" +cp "$FIX/symcyc/a.awk" "$FIX/symcyc/b.awk" "$symcyc/" +ln -s a.awk "$symcyc/alias.awk" +dist="$TMP/dist2d" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$symcyc/a.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"cycle back to entry"* ]]; then ok "entry_cycle_symlink_rejected"; else ng "entry_cycle_symlink_rejected" "exit=$st: $err"; fi + +# --- 実在しない "./x.awk" は正規化せずそのまま残す (AWKPATH 落ち防止) --- +dist="$TMP/distdm" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/dotmiss/main.awk" >/dev/null; then + if grep -qF '@include "./nosuch.awk"' "$dist/main.awk"; then ok "unresolved_dotslash_kept"; else ng "unresolved_dotslash_kept"; fi +else + ng "unresolved_dotslash_kept" "exit != 0" +fi + # --- "./x.awk" 形式の include は正規化されて dist に書き換わる --- dist="$TMP/distds" if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/dotslash/main.awk" >/dev/null; then @@ -160,6 +180,34 @@ fi leftovers=$(find "$TMP/dist1" "$TMP/dist6" -name ".hawk-desugar.*" 2>/dev/null | wc -l) if [[ "$leftovers" -eq 0 ]]; then ok "no_scratch_leftovers"; else ng "no_scratch_leftovers" "$leftovers scratch files remain"; fi +# --- 出力先が既存ディレクトリなら exit 1 (mv がディレクトリ内へ移動してしまうため) --- +dist="$TMP/distdir" +mkdir -p "$dist/solo.awk" +set +e +HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" >/dev/null 2>&1 +st=$? +set -e +if [[ "$st" -eq 1 && -d "$dist/solo.awk" ]]; then ok "dir_output_rejected"; else ng "dir_output_rejected" "exit=$st"; fi + +# --- HAWK_DIST が source 木の中の別ファイルを指すと拒否 (marker 保護) --- +srctree="$TMP/srctree" +mkdir -p "$srctree/sub" +cp "$FIX/solo.awk" "$srctree/main.awk" +printf 'BEGIN { print "another source" }\n' > "$srctree/sub/main.awk" +before_hash=$(cksum < "$srctree/sub/main.awk") +set +e +err=$(HAWK_DIST="$srctree/sub" "$LIBS" desugar "$srctree/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite"* ]]; then ok "dist_over_source_rejected"; else ng "dist_over_source_rejected" "exit=$st: $err"; fi +after_hash=$(cksum < "$srctree/sub/main.awk") +if [[ "$before_hash" == "$after_hash" ]]; then ok "dist_over_source_untouched"; else ng "dist_over_source_untouched" "source file was modified"; fi + +# --- marker のある dist への再実行 (上書き) は成功する --- +dist="$TMP/distrerun" +HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null; then ok "rerun_over_marker_ok"; else ng "rerun_over_marker_ok" "exit != 0"; fi + # --- HAWK_DIST 未指定なら cwd の dist/ --- work="$TMP/work"; mkdir -p "$work" cp "$FIX/solo.awk" "$work/main.awk" From e3f3e31e66b80861c022cc7abd37e679f8c6d4bc Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 16:33:16 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix(hawk-libs):=20publish=20=E3=82=AD?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=81=AE=E9=85=8D=E5=88=97=E5=8C=96=E3=81=A8?= =?UTF-8?q?=E5=88=A5=E5=90=8D=20include=20=E3=81=AE=E6=8B=92=E5=90=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃2件(PR #118)への対応: - publish キューを空白区切り文字列から bash 配列に変更。 HAWK_DIST に空白入りパスを渡すと read の解析が壊れて publish が失敗/誤動作する問題を解消 - 同一実体 (symlink/hardlink) を別名で include するケースを exit 1 で拒否。mirror 後は別 inode になり gawk の重複 include 排除が効かず、定義・BEGIN が二重実行されるため publish の世代混在窓 (generation directory による単一スイッチ ポイント化) は dist レイアウト規約の再設計が必要なため #119 に追記。 Co-Authored-By: Claude --- libexec/hawk-libs | 27 +++++++++++++------ tests/unit/desugar/fixtures/aliasinc/main.awk | 3 +++ tests/unit/desugar/fixtures/aliasinc/x.awk | 1 + tests/unit/desugar/run.sh | 20 ++++++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 tests/unit/desugar/fixtures/aliasinc/main.awk create mode 100644 tests/unit/desugar/fixtures/aliasinc/x.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index ebbde15e..ecd02e73 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -57,12 +57,14 @@ case "$subcmd" in src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " _scratches="" - _publish="" + # publish キューは配列で持つ (パスに空白が入っても壊れないように) + _pub_tmp=() + _pub_out=() # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 _desugar_one() { - local rel="$1" in out tmp inc incs + local rel="$1" in out tmp inc incs prior case "$_seen" in *" $rel "*) return 0 ;; esac case "$rel" in ../*|*/../*) @@ -70,6 +72,15 @@ case "$subcmd" in exit 1 ;; esac + # 同一実体 (symlink/hardlink) を別名で include すると、mirror 後は + # 別 inode になり gawk の重複 include 排除が効かず定義が二重実行される。 + # 別名での include は拒否する + for prior in $_seen; do + if [[ "$src_root/$rel" -ef "$src_root/$prior" ]]; then + echo "[hawk-libs] desugar failed: include aliases already-included file: $rel (same file as $prior)" >&2 + exit 1 + fi + done _seen="$_seen$rel " in="$src_root/$rel" out="$dist/$rel" @@ -183,18 +194,18 @@ case "$subcmd" in [[ -n "$inc" ]] && _desugar_one "$inc" done <<< "$incs" fi - _publish="$_publish$tmp $out"$'\n' + _pub_tmp+=("$tmp") + _pub_out+=("$out") return 0 } entry_rel="$(basename "$src")" _desugar_one "$entry_rel" # closure 全体が成功してから一括 publish (深い子から、entry が最後) - while read -r t o; do - [[ -n "$t" ]] || continue - chmod 644 "$t" - mv "$t" "$o" - done <<< "$_publish" + for ((i = 0; i < ${#_pub_tmp[@]}; i++)); do + chmod 644 "${_pub_tmp[$i]}" + mv "${_pub_tmp[$i]}" "${_pub_out[$i]}" + done : > "$dist/.hawk-dist" echo "$dist/$entry_rel" ;; diff --git a/tests/unit/desugar/fixtures/aliasinc/main.awk b/tests/unit/desugar/fixtures/aliasinc/main.awk new file mode 100644 index 00000000..50fd789e --- /dev/null +++ b/tests/unit/desugar/fixtures/aliasinc/main.awk @@ -0,0 +1,3 @@ +@include "x.awk" +@include "alias.awk" +BEGIN { print "aliasinc" } diff --git a/tests/unit/desugar/fixtures/aliasinc/x.awk b/tests/unit/desugar/fixtures/aliasinc/x.awk new file mode 100644 index 00000000..75b9156e --- /dev/null +++ b/tests/unit/desugar/fixtures/aliasinc/x.awk @@ -0,0 +1 @@ +function ai_x() { return 1 } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index ba54275c..24fc08fe 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -180,6 +180,26 @@ fi leftovers=$(find "$TMP/dist1" "$TMP/dist6" -name ".hawk-desugar.*" 2>/dev/null | wc -l) if [[ "$leftovers" -eq 0 ]]; then ok "no_scratch_leftovers"; else ng "no_scratch_leftovers" "$leftovers scratch files remain"; fi +# --- 同一実体を別名で include すると exit 1 (mirror 後に定義が二重実行されるため) --- +aliasinc="$TMP/aliasinc" +mkdir -p "$aliasinc" +cp "$FIX/aliasinc/main.awk" "$FIX/aliasinc/x.awk" "$aliasinc/" +ln -s x.awk "$aliasinc/alias.awk" +dist="$TMP/distai" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$aliasinc/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"aliases already-included file"* ]]; then ok "alias_include_rejected"; else ng "alias_include_rejected" "exit=$st: $err"; fi + +# --- 空白入り HAWK_DIST でも publish が壊れない --- +dist="$TMP/dist with spaces" +if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null; then + if [[ -f "$dist/main.awk" && -f "$dist/app/page.awk" ]]; then ok "spaced_dist_publishes"; else ng "spaced_dist_publishes" "missing outputs"; fi +else + ng "spaced_dist_publishes" "exit != 0" +fi + # --- 出力先が既存ディレクトリなら exit 1 (mv がディレクトリ内へ移動してしまうため) --- dist="$TMP/distdir" mkdir -p "$dist/solo.awk" From a722a0b45388124e73385eeb3773823ffa838f21 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 21:05:56 +0900 Subject: [PATCH 11/22] =?UTF-8?q?fix(hawk-libs):=20=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=E5=85=A5=E3=82=8A=20include=20=E3=83=91=E3=82=B9=E3=81=AE?= =?UTF-8?q?=E6=8B=92=E5=90=A6=E3=81=A8=20marker=20=E3=81=AE=E4=BA=8B?= =?UTF-8?q?=E5=89=8D=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃2件(PR #118)への対応: - 空白入り include パスを exit 1 で拒否。_seen の空白区切り 訪問済み判定がトークン単位の部分一致を起こし、dist に欠けた ファイルを黙って作るため。bash 3 互換 (連想配列不使用) を 保ったまま fail loud にする - .hawk-dist marker が regular file 以外の場合を staging 前に 検証して exit 1。publish 後の marker 作成失敗だと 「exit 1 なのに成果物は更新済み」の不整合が起きるため Co-Authored-By: Claude --- libexec/hawk-libs | 12 ++++++++++++ .../unit/desugar/fixtures/spacename/main.awk | 2 ++ tests/unit/desugar/fixtures/spacename/x y.awk | 1 + tests/unit/desugar/run.sh | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+) create mode 100644 tests/unit/desugar/fixtures/spacename/main.awk create mode 100644 tests/unit/desugar/fixtures/spacename/x y.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index ecd02e73..23fb4480 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -54,6 +54,12 @@ case "$subcmd" in fi dist="${HAWK_DIST:-dist}" mkdir -p "$dist" + # marker が regular file 以外だと publish 後の作成で失敗し + # 「exit 1 なのに成果物は更新済み」になるため、staging 前に検証する + if [[ -e "$dist/.hawk-dist" && ! -f "$dist/.hawk-dist" ]]; then + echo "[hawk-libs] desugar failed: $dist/.hawk-dist exists but is not a regular file" >&2 + exit 1 + fi src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " _scratches="" @@ -71,6 +77,12 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: include escapes project root via '..': $rel" >&2 exit 1 ;; + *" "*) + # _seen は空白区切りなので空白入りパスは訪問済み判定を誤り、 + # dist に欠けたファイルを黙って作る。非サポートとして明示的に失敗させる + echo "[hawk-libs] desugar failed: include path contains spaces (unsupported): $rel" >&2 + exit 1 + ;; esac # 同一実体 (symlink/hardlink) を別名で include すると、mirror 後は # 別 inode になり gawk の重複 include 排除が効かず定義が二重実行される。 diff --git a/tests/unit/desugar/fixtures/spacename/main.awk b/tests/unit/desugar/fixtures/spacename/main.awk new file mode 100644 index 00000000..5ec8fe26 --- /dev/null +++ b/tests/unit/desugar/fixtures/spacename/main.awk @@ -0,0 +1,2 @@ +@include "x y.awk" +BEGIN { print "spacename" } diff --git a/tests/unit/desugar/fixtures/spacename/x y.awk b/tests/unit/desugar/fixtures/spacename/x y.awk new file mode 100644 index 00000000..43b46c62 --- /dev/null +++ b/tests/unit/desugar/fixtures/spacename/x y.awk @@ -0,0 +1 @@ +function sn_x() { return 1 } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 24fc08fe..8ea217ce 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -200,6 +200,25 @@ else ng "spaced_dist_publishes" "exit != 0" fi +# --- 空白入り include パスは exit 1 (_seen の区切りと衝突するため非サポート) --- +dist="$TMP/distsn" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/spacename/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"contains spaces"* ]]; then ok "spaced_include_rejected"; else ng "spaced_include_rejected" "exit=$st: $err"; fi +if [[ ! -e "$dist/main.awk" ]]; then ok "spaced_include_publishes_nothing"; else ng "spaced_include_publishes_nothing" "dist/main.awk exists"; fi + +# --- marker が regular file 以外なら staging 前に exit 1 --- +dist="$TMP/distmk" +mkdir -p "$dist/.hawk-dist" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"not a regular file"* ]]; then ok "invalid_marker_rejected"; else ng "invalid_marker_rejected" "exit=$st: $err"; fi +if [[ ! -e "$dist/solo.awk" ]]; then ok "invalid_marker_publishes_nothing"; else ng "invalid_marker_publishes_nothing" "dist/solo.awk exists"; fi + # --- 出力先が既存ディレクトリなら exit 1 (mv がディレクトリ内へ移動してしまうため) --- dist="$TMP/distdir" mkdir -p "$dist/solo.awk" From 2206ba49ee8466f0b8b589503f947833f4b80150 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 21:11:08 +0900 Subject: [PATCH 12/22] =?UTF-8?q?test(desugar):=20alias=5Finclude=20?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E6=99=82=E3=81=AB=E4=BD=95=E3=82=82=20publis?= =?UTF-8?q?h=20=E3=81=95=E3=82=8C=E3=81=AA=E3=81=84=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=82=92=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 他の失敗系テストと同様の publishes_nothing チェックが alias_include_rejected にだけ欠けていた (coderabbit 指摘)。 Co-Authored-By: Claude --- tests/unit/desugar/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 8ea217ce..4a391349 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -191,6 +191,7 @@ err=$(HAWK_DIST="$dist" "$LIBS" desugar "$aliasinc/main.awk" 2>&1 >/dev/null) st=$? set -e if [[ "$st" -eq 1 && "$err" == *"aliases already-included file"* ]]; then ok "alias_include_rejected"; else ng "alias_include_rejected" "exit=$st: $err"; fi +if [[ ! -e "$dist/main.awk" ]]; then ok "alias_include_publishes_nothing"; else ng "alias_include_publishes_nothing" "dist/main.awk exists"; fi # --- 空白入り HAWK_DIST でも publish が壊れない --- dist="$TMP/dist with spaces" From a48f18afa844867eac57b39ec834a650e3a38baf Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 21:26:25 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix(hawk-libs):=20marker=20=E3=81=AE?= =?UTF-8?q?=E4=BA=8B=E5=89=8D=E6=A4=9C=E8=A8=BC=E5=BC=B7=E5=8C=96=E3=81=A8?= =?UTF-8?q?=20scratch=20cleanup=20=E3=81=AE=20quote=20=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃3件(PR #118)への対応: - marker が書込不可の場合を staging 前に検証。publish 完了後の marker 更新失敗だと「exit 1 なのに成果物は更新済み」になるため - symlink の marker を拒否。-f が symlink を辿るため、marker 偽装で 上書き保護を迂回でき、外部ターゲットの truncate も起きるため - scratch cleanup を空白区切り文字列 trap から配列 + cleanup 関数に 変更。空白入り HAWK_DIST で trap の word splitting が無関係の ファイルを削除しうるため Co-Authored-By: Claude --- libexec/hawk-libs | 42 +++++++++++++++++++++++++-------------- tests/unit/desugar/run.sh | 28 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 23fb4480..f1c480e4 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -54,16 +54,34 @@ case "$subcmd" in fi dist="${HAWK_DIST:-dist}" mkdir -p "$dist" - # marker が regular file 以外だと publish 後の作成で失敗し - # 「exit 1 なのに成果物は更新済み」になるため、staging 前に検証する - if [[ -e "$dist/.hawk-dist" && ! -f "$dist/.hawk-dist" ]]; then - echo "[hawk-libs] desugar failed: $dist/.hawk-dist exists but is not a regular file" >&2 + # marker の異常 (symlink / 非 regular file / 書込不可) は publish 後の + # 作成・更新で失敗し「exit 1 なのに成果物は更新済み」になるため、 + # staging 前に検証する。-f は symlink を辿るので -L を先に見る + # (symlink marker は上書き保護の偽装と外部ターゲットの truncate を許すため拒否) + if [[ -L "$dist/.hawk-dist" ]]; then + echo "[hawk-libs] desugar failed: $dist/.hawk-dist is a symlink" >&2 exit 1 fi + if [[ -e "$dist/.hawk-dist" ]]; then + if [[ ! -f "$dist/.hawk-dist" ]]; then + echo "[hawk-libs] desugar failed: $dist/.hawk-dist exists but is not a regular file" >&2 + exit 1 + fi + if [[ ! -w "$dist/.hawk-dist" ]]; then + echo "[hawk-libs] desugar failed: $dist/.hawk-dist is not writable" >&2 + exit 1 + fi + fi src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " - _scratches="" - # publish キューは配列で持つ (パスに空白が入っても壊れないように) + # scratch と publish キューは配列で持つ + # (空白入りパスが word splitting で別引数に化けるのを防ぐ) + _scratch_arr=() + _cleanup_scratches() { + local f + for f in "${_scratch_arr[@]}"; do rm -f "$f"; done + } + trap _cleanup_scratches EXIT _pub_tmp=() _pub_out=() @@ -121,9 +139,7 @@ case "$subcmd" in # 固定名 ($out.tmp) が実在する成果物と衝突する問題も避ける。 tmp="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" # 途中失敗時の scratch 残留を防ぐ (mv 済みの名前への rm -f は no-op) - _scratches="$_scratches $tmp" - # shellcheck disable=SC2064 - trap "rm -f$_scratches" EXIT + _scratch_arr+=("$tmp") # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 # HAWK_LIB (リポジトリルート) に cd してから実行する。 @@ -139,9 +155,7 @@ case "$subcmd" in # ("./x.awk" はローカル解決のみだが "x.awk" は AWKPATH に落ちるため、 # 実在しないパスを書き換えると挙動が変わってしまう) tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" - _scratches="$_scratches $tmp2" - # shellcheck disable=SC2064 - trap "rm -f$_scratches" EXIT + _scratch_arr+=("$tmp2") if ! gawk -v root="$src_root" ' function exists(f, t, r) { r = (getline t < f) @@ -181,9 +195,7 @@ case "$subcmd" in # gawk は '/' を含む @include パスを AWKPATH 探索しないため、 # dist 接頭の実パスに書き換える tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" - _scratches="$_scratches $tmp2" - # shellcheck disable=SC2064 - trap "rm -f$_scratches" EXIT + _scratch_arr+=("$tmp2") if ! gawk -v dist="$dist" -v list="$incs" ' BEGIN { n = split(list, a, "\n"); for (i = 1; i <= n; i++) if (a[i] != "") L[a[i]] = 1 } { diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 4a391349..35c5b4cf 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -197,6 +197,8 @@ if [[ ! -e "$dist/main.awk" ]]; then ok "alias_include_publishes_nothing"; else dist="$TMP/dist with spaces" if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null; then if [[ -f "$dist/main.awk" && -f "$dist/app/page.awk" ]]; then ok "spaced_dist_publishes"; else ng "spaced_dist_publishes" "missing outputs"; fi + leftovers=$(find "$dist" -name ".hawk-desugar.*" 2>/dev/null | wc -l) + if [[ "$leftovers" -eq 0 ]]; then ok "spaced_dist_no_scratch_leftovers"; else ng "spaced_dist_no_scratch_leftovers" "$leftovers scratch files remain"; fi else ng "spaced_dist_publishes" "exit != 0" fi @@ -210,6 +212,32 @@ set -e if [[ "$st" -eq 1 && "$err" == *"contains spaces"* ]]; then ok "spaced_include_rejected"; else ng "spaced_include_rejected" "exit=$st: $err"; fi if [[ ! -e "$dist/main.awk" ]]; then ok "spaced_include_publishes_nothing"; else ng "spaced_include_publishes_nothing" "dist/main.awk exists"; fi +# --- marker が書込不可なら staging 前に exit 1 (publish 後の更新失敗を防ぐ) --- +dist="$TMP/distmkw" +HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" >/dev/null +before_hash=$(cksum < "$dist/solo.awk") +chmod a-w "$dist/.hawk-dist" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" 2>&1 >/dev/null) +st=$? +set -e +chmod u+w "$dist/.hawk-dist" +if [[ "$st" -eq 1 && "$err" == *"not writable"* ]]; then ok "unwritable_marker_rejected"; else ng "unwritable_marker_rejected" "exit=$st: $err"; fi +after_hash=$(cksum < "$dist/solo.awk") +if [[ "$before_hash" == "$after_hash" && ! -e "$dist/main.awk" ]]; then ok "unwritable_marker_publishes_nothing"; else ng "unwritable_marker_publishes_nothing"; fi + +# --- marker が symlink なら staging 前に exit 1 (上書き保護の偽装を防ぐ) --- +dist="$TMP/distmks" +mkdir -p "$dist" +printf 'marker target\n' > "$TMP/ext-marker" +ln -s "$TMP/ext-marker" "$dist/.hawk-dist" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"is a symlink"* ]]; then ok "symlink_marker_rejected"; else ng "symlink_marker_rejected" "exit=$st: $err"; fi +if [[ "$(cat "$TMP/ext-marker")" == "marker target" ]]; then ok "symlink_marker_target_untouched"; else ng "symlink_marker_target_untouched"; fi + # --- marker が regular file 以外なら staging 前に exit 1 --- dist="$TMP/distmk" mkdir -p "$dist/.hawk-dist" From 620aa1270c2e17e6768473d041b9f4fe79a3bad2 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Fri, 10 Jul 2026 21:54:08 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix(hawk-libs):=20marker=20=E3=81=AErel?= =?UTF-8?q?=E5=8D=98=E4=BD=8D=E8=A8=98=E9=8C=B2=E3=83=BBglob=E5=B1=95?= =?UTF-8?q?=E9=96=8B=E5=81=9C=E6=AD=A2=E3=83=BBroot=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃3件(PR #118)への対応: - .hawk-dist marker を空ファイルから「publish した rel パスの 1行1件記録」に変更。空 marker だと最初の成功後にディレクトリ 全体を信頼してしまい、HAWK_DIST が source 木の中を指す場合に 無関係な source ファイルまで上書き対象になるため。上書き許可は marker に記録済みの rel に限定 - alias 検出ループを set -f で囲み glob 展開を停止。include 名の glob 文字 (x*.awk 等) がカレントディレクトリに展開されて 無関係ファイルを誤 alias 判定するため - root では chmod a-w が -w 判定に効かないため、unwritable_marker テストを uid 0 のとき SKIP に変更 (root コンテナ CI 対策) Co-Authored-By: Claude --- libexec/hawk-libs | 39 ++++++++++++++++---- tests/unit/desugar/run.sh | 77 +++++++++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 20 deletions(-) diff --git a/libexec/hawk-libs b/libexec/hawk-libs index f1c480e4..f33dcd6a 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -84,6 +84,7 @@ case "$subcmd" in trap _cleanup_scratches EXIT _pub_tmp=() _pub_out=() + _pub_rel=() # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 @@ -104,13 +105,20 @@ case "$subcmd" in esac # 同一実体 (symlink/hardlink) を別名で include すると、mirror 後は # 別 inode になり gawk の重複 include 排除が効かず定義が二重実行される。 - # 別名での include は拒否する + # 別名での include は拒否する。 + # _seen は空白区切りの未クォート展開なので、include 名に glob 文字 + # (例: "x*.awk") が含まれるとカレントディレクトリに対してパス名展開され、 + # 無関係な同名ファイルを誤って alias 扱いしてしまう。glob 文字はファイル名として + # 正当なため、比較対象は文字列のまま扱いたい。noglob で展開だけを止める + set -f for prior in $_seen; do if [[ "$src_root/$rel" -ef "$src_root/$prior" ]]; then + set +f echo "[hawk-libs] desugar failed: include aliases already-included file: $rel (same file as $prior)" >&2 exit 1 fi done + set +f _seen="$_seen$rel " in="$src_root/$rel" out="$dist/$rel" @@ -125,12 +133,16 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: output path is a directory: $out" >&2 exit 1 fi - # 過去の desugar が作った dist であることを .hawk-dist マーカーで確認する。 - # マーカーのないディレクトリ内の既存ファイルは source とみなし上書きしない - # (HAWK_DIST が source 木の中を指すと -ef では検出できない別ファイルを潰すため) - if [[ -e "$out" && ! -e "$dist/.hawk-dist" ]]; then - echo "[hawk-libs] desugar failed: refusing to overwrite existing file in non-dist directory: $out (remove it or the whole directory if it is a stale dist)" >&2 - exit 1 + # 過去の desugar が publish した rel パスであることを .hawk-dist マーカーで確認する。 + # マーカーが無い、または rel が記録されていない既存ファイルは source とみなし + # 上書きしない (HAWK_DIST が source 木の中を指すと -ef では検出できない別ファイルを + # 潰すため。marker を空ファイルにするだけだと最初の成功後はディレクトリ全体を + # 信頼してしまい、無関係な source ファイルまで上書き対象になる) + if [[ -e "$out" ]]; then + if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF "$rel" "$dist/.hawk-dist"; then + echo "[hawk-libs] desugar failed: refusing to overwrite existing file in non-dist directory: $out (remove it or the whole directory if it is a stale dist)" >&2 + exit 1 + fi fi mkdir -p "$(dirname "$out")" # 稼働中の hawk-serve が dist の成果物を再ロードしうるため、 @@ -220,6 +232,7 @@ case "$subcmd" in fi _pub_tmp+=("$tmp") _pub_out+=("$out") + _pub_rel+=("$rel") return 0 } @@ -230,7 +243,17 @@ case "$subcmd" in chmod 644 "${_pub_tmp[$i]}" mv "${_pub_tmp[$i]}" "${_pub_out[$i]}" done - : > "$dist/.hawk-dist" + # marker は「今回 publish した rel」を既存の記録に追記して保持する。 + # 空ファイルのままだと最初の成功後にディレクトリ全体を信頼してしまうため、 + # rel を1行1件記録し、次回以降の上書き可否を rel 単位で判定できるようにする。 + # 更新も稼働中の hawk-serve と競合しないよう mktemp + mv でアトミックに行う + _marker_tmp="$(mktemp "$dist/.hawk-dist.XXXXXX")" + _scratch_arr+=("$_marker_tmp") + { + [[ -f "$dist/.hawk-dist" ]] && cat "$dist/.hawk-dist" + printf '%s\n' "${_pub_rel[@]}" + } | sort -u > "$_marker_tmp" + mv "$_marker_tmp" "$dist/.hawk-dist" echo "$dist/$entry_rel" ;; *) diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 35c5b4cf..02206b9c 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -9,6 +9,12 @@ FIX=tests/unit/desugar/fixtures ok() { printf " PASS: %s\n" "$1"; PASS=$((PASS+1)); } ng() { printf " FAIL: %s%s\n" "$1" "${2:+ ($2)}"; FAIL=$((FAIL+1)); } +skip() { printf " SKIP: %s%s\n" "$1" "${2:+ ($2)}"; } + +# root では chmod a-w が -w 判定にも truncate 阻止にも効かないため、 +# root 専用フィクスチャを別途用意するまでは該当アサーションを SKIP する +IS_ROOT=0 +[[ "$(id -u)" -eq 0 ]] && IS_ROOT=1 TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT @@ -213,18 +219,25 @@ if [[ "$st" -eq 1 && "$err" == *"contains spaces"* ]]; then ok "spaced_include_r if [[ ! -e "$dist/main.awk" ]]; then ok "spaced_include_publishes_nothing"; else ng "spaced_include_publishes_nothing" "dist/main.awk exists"; fi # --- marker が書込不可なら staging 前に exit 1 (publish 後の更新失敗を防ぐ) --- -dist="$TMP/distmkw" -HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" >/dev/null -before_hash=$(cksum < "$dist/solo.awk") -chmod a-w "$dist/.hawk-dist" -set +e -err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" 2>&1 >/dev/null) -st=$? -set -e -chmod u+w "$dist/.hawk-dist" -if [[ "$st" -eq 1 && "$err" == *"not writable"* ]]; then ok "unwritable_marker_rejected"; else ng "unwritable_marker_rejected" "exit=$st: $err"; fi -after_hash=$(cksum < "$dist/solo.awk") -if [[ "$before_hash" == "$after_hash" && ! -e "$dist/main.awk" ]]; then ok "unwritable_marker_publishes_nothing"; else ng "unwritable_marker_publishes_nothing"; fi +# root (id -u == 0) では chmod a-w が -w 判定にも truncate 阻止にも効かず +# exit=0 になってしまうため、root 実行時はこの2アサーションを SKIP する +if [[ "$IS_ROOT" -eq 1 ]]; then + skip "unwritable_marker_rejected" "running as root, chmod a-w has no effect" + skip "unwritable_marker_publishes_nothing" "running as root, chmod a-w has no effect" +else + dist="$TMP/distmkw" + HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" >/dev/null + before_hash=$(cksum < "$dist/solo.awk") + chmod a-w "$dist/.hawk-dist" + set +e + err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" 2>&1 >/dev/null) + st=$? + set -e + chmod u+w "$dist/.hawk-dist" + if [[ "$st" -eq 1 && "$err" == *"not writable"* ]]; then ok "unwritable_marker_rejected"; else ng "unwritable_marker_rejected" "exit=$st: $err"; fi + after_hash=$(cksum < "$dist/solo.awk") + if [[ "$before_hash" == "$after_hash" && ! -e "$dist/main.awk" ]]; then ok "unwritable_marker_publishes_nothing"; else ng "unwritable_marker_publishes_nothing"; fi +fi # --- marker が symlink なら staging 前に exit 1 (上書き保護の偽装を防ぐ) --- dist="$TMP/distmks" @@ -276,6 +289,46 @@ dist="$TMP/distrerun" HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null; then ok "rerun_over_marker_ok"; else ng "rerun_over_marker_ok" "exit != 0"; fi +# --- marker は publish 済み rel のみ信頼し、同じ dist 配下の無関係な既存ファイルは +# 引き続き上書き保護される (marker が空ファイルだと最初の成功後に +# ディレクトリ全体を信頼してしまう問題の再現) --- +srcsub2="$TMP/srcsub2" +mkdir -p "$srcsub2/sub" +cp "$FIX/solo.awk" "$srcsub2/main.awk" +HAWK_DIST="$srcsub2/sub" "$LIBS" desugar "$srcsub2/main.awk" >/dev/null +printf 'BEGIN { print "other-source" }\n' > "$srcsub2/sub/other.awk" +before_hash=$(cksum < "$srcsub2/sub/other.awk") +cp "$FIX/solo.awk" "$srcsub2/other.awk" +set +e +err=$(HAWK_DIST="$srcsub2/sub" "$LIBS" desugar "$srcsub2/other.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite"* ]]; then ok "marker_does_not_bless_unrelated_files"; else ng "marker_does_not_bless_unrelated_files" "exit=$st: $err"; fi +after_hash=$(cksum < "$srcsub2/sub/other.awk") +if [[ "$before_hash" == "$after_hash" ]]; then ok "marker_does_not_bless_unrelated_files_untouched"; else ng "marker_does_not_bless_unrelated_files_untouched" "source file was modified"; fi + +# --- include 名に glob 文字が含まれても、_seen の未クォート展開で +# カレントディレクトリの同名ファイルと誤って alias 判定されない --- +globinc="$TMP/globinc" +mkdir -p "$globinc" +cat > "$globinc/main.awk" <<'EOF' +@include "x*.awk" +@include "xfoo.awk" +BEGIN { print "glob-ok" } +EOF +printf 'BEGIN { }\n' > "$globinc/x*.awk" +printf 'BEGIN { }\n' > "$globinc/xfoo.awk" +dist="$TMP/distglob" +set +e +out=$(cd "$globinc" && HAWK_DIST="$dist" "$LIBS_ABS" desugar main.awk 2>&1) +st=$? +set -e +if [[ "$st" -eq 0 && -f "$dist/x*.awk" && -f "$dist/xfoo.awk" ]]; then + ok "glob_include_names_not_confused" +else + ng "glob_include_names_not_confused" "exit=$st: $out" +fi + # --- HAWK_DIST 未指定なら cwd の dist/ --- work="$TMP/work"; mkdir -p "$work" cp "$FIX/solo.awk" "$work/main.awk" From eb968d3272c45c848835b17f58979cf57d22fed1 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Sat, 11 Jul 2026 13:20:15 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix(hawk-libs):=20marker=20=E4=BA=88?= =?UTF-8?q?=E7=B4=84=E5=90=8D=E3=81=A8=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7?= =?UTF-8?q?=E3=82=AF=E3=83=88=E5=86=85=E7=B5=B6=E5=AF=BE=E3=83=91=E3=82=B9?= =?UTF-8?q?=20include=20=E3=81=AE=E6=8B=92=E5=90=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃2件(PR #118)への対応: - ".hawk-dist" という include 名を予約名として exit 1。 dist ルートの marker と同じパスに publish されると、publish 後の marker 更新が成果物を書き潰して実行時に壊れるため - プロジェクト内 (src_root 配下) を指す絶対パス include を exit 1。 mirror 後も原本 (未 desugar) を直接ロードして定義が二重実行される ため相対パスに限定する。外部の絶対パス (共有ライブラリ等) は 従来どおり素通り Co-Authored-By: Claude --- libexec/hawk-libs | 18 +++++++++++ .../unit/desugar/fixtures/resname/.hawk-dist | 1 + tests/unit/desugar/fixtures/resname/main.awk | 2 ++ tests/unit/desugar/run.sh | 32 +++++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 tests/unit/desugar/fixtures/resname/.hawk-dist create mode 100644 tests/unit/desugar/fixtures/resname/main.awk diff --git a/libexec/hawk-libs b/libexec/hawk-libs index f33dcd6a..fcba9b64 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -102,6 +102,12 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: include path contains spaces (unsupported): $rel" >&2 exit 1 ;; + .hawk-dist) + # dist ルートの marker と同じパスに publish されると、 + # publish 後の marker 更新が成果物を書き潰すため予約名として拒否 + echo "[hawk-libs] desugar failed: '.hawk-dist' is reserved for the dist marker: $rel" >&2 + exit 1 + ;; esac # 同一実体 (symlink/hardlink) を別名で include すると、mirror 後は # 別 inode になり gawk の重複 include 排除が効かず定義が二重実行される。 @@ -191,6 +197,18 @@ case "$subcmd" in mv "$tmp2" "$tmp" incs="" while IFS= read -r inc; do + # 絶対パス include がプロジェクト内のファイルを指すと、mirror 後も + # 原本 (未 desugar) を直接ロードして定義が二重実行される。 + # プロジェクト内は相対パスに限定し、外部の絶対パス (共有ライブラリ等) は + # 従来どおり素通りで残す + if [[ "$inc" == /* ]]; then + if [[ -f "$inc" && "$(cd "$(dirname "$inc")" && pwd -P)/" == "$src_root/"* ]]; then + rm -f "$tmp" + echo "[hawk-libs] desugar failed: absolute include points inside project root (use a relative path): $inc" >&2 + exit 1 + fi + continue + fi # entry への back-edge は gawk が実行時に # "cannot include `X' and use it as a program file" で fatal になるため # desugar 時点で拒否する (entry を経由しない循環は gawk が重複 include を diff --git a/tests/unit/desugar/fixtures/resname/.hawk-dist b/tests/unit/desugar/fixtures/resname/.hawk-dist new file mode 100644 index 00000000..b0085637 --- /dev/null +++ b/tests/unit/desugar/fixtures/resname/.hawk-dist @@ -0,0 +1 @@ +function rn_x() { return 1 } diff --git a/tests/unit/desugar/fixtures/resname/main.awk b/tests/unit/desugar/fixtures/resname/main.awk new file mode 100644 index 00000000..251f3a7c --- /dev/null +++ b/tests/unit/desugar/fixtures/resname/main.awk @@ -0,0 +1,2 @@ +@include ".hawk-dist" +BEGIN { print "resname" } diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 02206b9c..8a0afb31 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -218,6 +218,38 @@ set -e if [[ "$st" -eq 1 && "$err" == *"contains spaces"* ]]; then ok "spaced_include_rejected"; else ng "spaced_include_rejected" "exit=$st: $err"; fi if [[ ! -e "$dist/main.awk" ]]; then ok "spaced_include_publishes_nothing"; else ng "spaced_include_publishes_nothing" "dist/main.awk exists"; fi +# --- ".hawk-dist" という include 名は marker の予約名として exit 1 --- +dist="$TMP/distrn" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/resname/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"reserved for the dist marker"* ]]; then ok "marker_name_include_rejected"; else ng "marker_name_include_rejected" "exit=$st: $err"; fi + +# --- プロジェクト内を指す絶対パス include は exit 1、外部の絶対パスは素通り --- +absinc="$TMP/absinc" +mkdir -p "$absinc" +cat > "$absinc/x.awk" << 'AWKEOF' +function abs_x() { return 1 } +AWKEOF +printf '@include "%s/x.awk"\nBEGIN { print "absinc" }\n' "$absinc" > "$absinc/main.awk" +dist="$TMP/distabs" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$absinc/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"absolute include points inside project root"* ]]; then ok "abs_include_inside_root_rejected"; else ng "abs_include_inside_root_rejected" "exit=$st: $err"; fi + +absext="$TMP/absext" +mkdir -p "$absext" +printf '@include "/no/such/external.awk"\nBEGIN { print "absext" }\n' > "$absext/main.awk" +dist="$TMP/distabse" +if HAWK_DIST="$dist" "$LIBS" desugar "$absext/main.awk" >/dev/null; then + if grep -qF '@include "/no/such/external.awk"' "$dist/main.awk"; then ok "abs_include_external_passthrough"; else ng "abs_include_external_passthrough"; fi +else + ng "abs_include_external_passthrough" "exit != 0" +fi + # --- marker が書込不可なら staging 前に exit 1 (publish 後の更新失敗を防ぐ) --- # root (id -u == 0) では chmod a-w が -w 判定にも truncate 阻止にも効かず # exit=0 になってしまうため、root 実行時はこの2アサーションを SKIP する From dec66f37161883bfb9f810e09345e8e8904d34c7 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Sat, 11 Jul 2026 13:40:55 +0900 Subject: [PATCH 16/22] =?UTF-8?q?fix(hawk-libs):=20marker=E8=AA=AD?= =?UTF-8?q?=E8=BE=BC=E6=A4=9C=E8=A8=BC=E3=83=BBgrep=E5=BC=95=E6=95=B0?= =?UTF-8?q?=E4=BF=9D=E8=AD=B7=E3=83=BBsymlink=E8=A7=A3=E6=B1=BA=E3=81=AE?= =?UTF-8?q?=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃4件(PR #118)への対応: - marker が書込可だが読込不可の場合を staging 前に検証。 publish 後の marker 更新パイプラインで cat の失敗が sort の exit 0 に隠れ、marker が空/不完全になって以降の再実行が 全部拒否されるため - marker 照合の grep に -- を追加。"-main.awk" のような ダッシュ始まりの正当なファイル名がオプションと誤解釈され 再実行不能になるため - 絶対パス include の外部判定で、ディレクトリだけでなく ファイル自体の symlink チェーンも解決 (最大40回、超過は symlink loop として exit 1)。外部パスを装った symlink が プロジェクト内の原本を指すと定義が二重実行されるため - 上書き保護の存在判定に -L を追加。dangling symlink は -e が false になり、出力先に残った古い source symlink が保護を すり抜けて上書きされるため Co-Authored-By: Claude --- libexec/hawk-libs | 43 +++++++++++++++++++++---- tests/unit/desugar/run.sh | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 6 deletions(-) diff --git a/libexec/hawk-libs b/libexec/hawk-libs index fcba9b64..280410bc 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -71,6 +71,14 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: $dist/.hawk-dist is not writable" >&2 exit 1 fi + # 書込可だが読込不可だと、末尾の marker 更新時の cat が + # パイプライン内で失敗しても sort 側の exit 0 に隠れ、 + # staging 済みの成果物はそのまま marker だけが空/不完全になる。 + # publish 前に読めることを確認しておく + if [[ ! -r "$dist/.hawk-dist" ]]; then + echo "[hawk-libs] desugar failed: $dist/.hawk-dist is not readable" >&2 + exit 1 + fi fi src_root="$(cd "$(dirname "$src")" && pwd -P)" _seen=" " @@ -144,8 +152,10 @@ case "$subcmd" in # 上書きしない (HAWK_DIST が source 木の中を指すと -ef では検出できない別ファイルを # 潰すため。marker を空ファイルにするだけだと最初の成功後はディレクトリ全体を # 信頼してしまい、無関係な source ファイルまで上書き対象になる) - if [[ -e "$out" ]]; then - if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF "$rel" "$dist/.hawk-dist"; then + # -e はダングリング symlink で false になるため、-L も見ないと + # 出力先に残った古い source symlink が保護をすり抜けて上書きされる + if [[ -e "$out" || -L "$out" ]]; then + if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF -- "$rel" "$dist/.hawk-dist"; then echo "[hawk-libs] desugar failed: refusing to overwrite existing file in non-dist directory: $out (remove it or the whole directory if it is a stale dist)" >&2 exit 1 fi @@ -202,10 +212,31 @@ case "$subcmd" in # プロジェクト内は相対パスに限定し、外部の絶対パス (共有ライブラリ等) は # 従来どおり素通りで残す if [[ "$inc" == /* ]]; then - if [[ -f "$inc" && "$(cd "$(dirname "$inc")" && pwd -P)/" == "$src_root/"* ]]; then - rm -f "$tmp" - echo "[hawk-libs] desugar failed: absolute include points inside project root (use a relative path): $inc" >&2 - exit 1 + if [[ -f "$inc" ]]; then + # ディレクトリだけでなくファイル自体も symlink 経由でプロジェクト内を + # 指せるため (例: /tmp/outside/alias.awk -> $src_root/x.awk)、 + # symlink チェーンをファイル自体まで辿ってから判定する + _abs_inc_resolved="$inc" + _abs_inc_hops=0 + while [[ -L "$_abs_inc_resolved" ]]; do + _abs_inc_hops=$((_abs_inc_hops + 1)) + if [[ $_abs_inc_hops -gt 40 ]]; then + rm -f "$tmp" + echo "[hawk-libs] desugar failed: symlink loop resolving absolute include: $inc" >&2 + exit 1 + fi + _abs_inc_target="$(readlink "$_abs_inc_resolved")" + if [[ "$_abs_inc_target" == /* ]]; then + _abs_inc_resolved="$_abs_inc_target" + else + _abs_inc_resolved="$(dirname "$_abs_inc_resolved")/$_abs_inc_target" + fi + done + if [[ "$(cd "$(dirname "$_abs_inc_resolved")" && pwd -P)/" == "$src_root/"* ]]; then + rm -f "$tmp" + echo "[hawk-libs] desugar failed: absolute include points inside project root (use a relative path): $inc" >&2 + exit 1 + fi fi continue fi diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index 8a0afb31..c6a5be19 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -374,5 +374,72 @@ st=$? set -e if [[ "$st" -eq 1 ]]; then ok "entry_not_found_exit1"; else ng "entry_not_found_exit1" "exit=$st"; fi +# --- marker が書込可だが読込不可なら staging 前に exit 1 +# (publish 後の marker 読込失敗を防ぐ) --- +if [[ "$IS_ROOT" -eq 1 ]]; then + skip "unreadable_marker_rejected" "running as root, chmod a-r has no effect" +else + dist="$TMP/distmkr" + HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" >/dev/null + before_hash=$(cksum < "$dist/solo.awk") + chmod a-r "$dist/.hawk-dist" + set +e + err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" 2>&1 >/dev/null) + st=$? + set -e + chmod u+r "$dist/.hawk-dist" + if [[ "$st" -eq 1 && "$err" == *"not readable"* ]]; then ok "unreadable_marker_rejected"; else ng "unreadable_marker_rejected" "exit=$st: $err"; fi + after_hash=$(cksum < "$dist/solo.awk") + if [[ "$before_hash" == "$after_hash" && ! -e "$dist/main.awk" ]]; then ok "unreadable_marker_publishes_nothing"; else ng "unreadable_marker_publishes_nothing"; fi +fi + +# --- rel が "-" で始まっても marker 照合の grep がオプションと誤解釈せず +# 再実行できる --- +dashinc="$TMP/dashinc" +mkdir -p "$dashinc" +cat > "$dashinc/-main.awk" << 'AWKEOF' +BEGIN { print "dash" } +AWKEOF +dist="$TMP/distdash" +set +e +out1=$(cd "$dashinc" && HAWK_DIST="$dist" "$LIBS_ABS" desugar ./-main.awk 2>&1) +st1=$? +out2=$(cd "$dashinc" && HAWK_DIST="$dist" "$LIBS_ABS" desugar ./-main.awk 2>&1) +st2=$? +set -e +if [[ "$st1" -eq 0 && "$st2" -eq 0 ]]; then ok "dash_prefixed_entry_rerunnable"; else ng "dash_prefixed_entry_rerunnable" "exit1=$st1 exit2=$st2: $out1 / $out2"; fi + +# --- プロジェクト外の絶対パス include が、実はプロジェクト内ファイルへの +# symlink である場合は拒否 (dist と source の二重定義を防ぐ) --- +abssym="$TMP/abssym" +mkdir -p "$abssym/project" "$abssym/outside" +cat > "$abssym/project/x.awk" << AWKEOF +@include "$abssym/outside/alias.awk" +function proj_x() { return 1 } +AWKEOF +cat > "$abssym/project/main.awk" << 'AWKEOF' +@include "x.awk" +BEGIN { print "abssym" } +AWKEOF +ln -s "$abssym/project/x.awk" "$abssym/outside/alias.awk" +dist="$TMP/distabssym" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$abssym/project/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"absolute include points inside project root"* ]]; then ok "abs_symlink_include_to_project_rejected"; else ng "abs_symlink_include_to_project_rejected" "exit=$st: $err"; fi + +# --- 出力先がダングリング symlink でも -e だけでなく -L も見て +# 上書き保護される (置き換えられずに残る) --- +dist="$TMP/distdangle" +mkdir -p "$dist" +ln -s "$TMP/missing-target" "$dist/solo.awk" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/solo.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 ]]; then ok "dangling_symlink_output_rejected"; else ng "dangling_symlink_output_rejected" "exit=$st: $err"; fi +if [[ -L "$dist/solo.awk" && ! -e "$dist/solo.awk" ]]; then ok "dangling_symlink_output_untouched"; else ng "dangling_symlink_output_untouched"; fi + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] From d44bdcdd7c31909061a53a4493fa2771803affd8 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Sat, 11 Jul 2026 18:16:04 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix(hawk-libs):=20=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E5=86=85=E3=81=B8=E3=81=AE?= =?UTF-8?q?=20hard=20link=20=E7=B5=B6=E5=AF=BE=E3=83=91=E3=82=B9=20include?= =?UTF-8?q?=20=E3=82=92=E6=8B=92=E5=90=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃(PR #118)への対応。 hard link は readlink で辿れずパス名も外部のままなので、symlink チェーン解決 + パス prefix 比較では検出できない。訪問済み プロジェクトファイルとの device/inode 比較 (-ef) を追加し、 mirror 後に原本 (未 desugar) が二重ロードされるのを防ぐ。 関数シグネチャのファイル跨ぎ参照 (checker が unknown function に なる問題) は型宣言共有と同根の設計制約のため #119 に追記。 Co-Authored-By: Claude --- libexec/hawk-libs | 13 +++++++++++++ tests/unit/desugar/run.sh | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 280410bc..d5fa2719 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -237,6 +237,19 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: absolute include points inside project root (use a relative path): $inc" >&2 exit 1 fi + # hard link は readlink で辿れずパス名も外部のままなので、 + # パス比較では検出できない。訪問済みプロジェクトファイルとの + # device/inode 比較 (-ef) で同一実体を検出する + set -f + for prior in $_seen; do + if [[ "$inc" -ef "$src_root/$prior" ]]; then + set +f + rm -f "$tmp" + echo "[hawk-libs] desugar failed: absolute include is the same file as project file: $inc (same file as $prior)" >&2 + exit 1 + fi + done + set +f fi continue fi diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index c6a5be19..a7ec3465 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -250,6 +250,20 @@ else ng "abs_include_external_passthrough" "exit != 0" fi +# --- プロジェクト内のファイルへの hard link を指す絶対パス include も exit 1 --- +abshard="$TMP/abshard" +outside2="$TMP/outside2" +mkdir -p "$abshard" "$outside2" +printf '@include "x.awk"\nBEGIN { print "abshard" }\n' > "$abshard/main.awk" +printf '@include "%s/alias.awk"\nfunction ah_x() { return 1 }\n' "$outside2" > "$abshard/x.awk" +ln "$abshard/x.awk" "$outside2/alias.awk" +dist="$TMP/distah" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$abshard/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"same file as"* ]]; then ok "abs_hardlink_include_to_project_rejected"; else ng "abs_hardlink_include_to_project_rejected" "exit=$st: $err"; fi + # --- marker が書込不可なら staging 前に exit 1 (publish 後の更新失敗を防ぐ) --- # root (id -u == 0) では chmod a-w が -w 判定にも truncate 阻止にも効かず # exit=0 になってしまうため、root 実行時はこの2アサーションを SKIP する From cfd53fccebfa9d0b854c8d363e917b516403f458 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Tue, 14 Jul 2026 10:15:20 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix(hawk-libs):=20closure=E7=A2=BA?= =?UTF-8?q?=E5=AE=9A=E5=BE=8C=E3=81=AEhard=20link=E7=85=A7=E5=90=88?= =?UTF-8?q?=E3=83=BBmarker=20src=5Froot=E8=A8=98=E9=8C=B2=E3=83=BBsymlink?= =?UTF-8?q?=20dist=E6=A4=9C=E8=A8=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃3件(PR #118)への対応: - 絶対パス include の hard link 照合を closure 確定後 (publish 前) に移動。staging 中の _seen は処理順依存で、後続 sibling への hard link を見逃すため - marker の行形式を rel から「src_root TAB rel」のペアに変更。 rel だけの照合だと、別ディレクトリの同名 entry (apps/a/main.awk と apps/b/main.awk) が同じ dist を使ったとき後勝ちが前の成果物を 無警告で置換するため。dist パス自体の一意化は #119 の設計課題 - 出力先の親ディレクトリの物理パスが dist の物理パス配下であることを staging 時に検証。dist 配下のディレクトリが外部への symlink に 差し替わると publish が dist の外に書いてしまうため Co-Authored-By: Claude --- libexec/hawk-libs | 54 ++++++++++++++++++++++++++----------- tests/unit/desugar/run.sh | 57 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/libexec/hawk-libs b/libexec/hawk-libs index d5fa2719..4d0bd8fc 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -81,6 +81,7 @@ case "$subcmd" in fi fi src_root="$(cd "$(dirname "$src")" && pwd -P)" + dist_phys="$(cd "$dist" && pwd -P)" _seen=" " # scratch と publish キューは配列で持つ # (空白入りパスが word splitting で別引数に化けるのを防ぐ) @@ -93,6 +94,10 @@ case "$subcmd" in _pub_tmp=() _pub_out=() _pub_rel=() + # プロジェクト内ファイルへの hard link を指す絶対パス include を、closure 完了後に + # まとめて照合するための保留キュー (staging 中は _seen が未確定のため、後続 sibling + # を hard link 経由で見逃す。詳細は下の post-closure 検査を参照) + _abs_ext=() # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 @@ -155,12 +160,25 @@ case "$subcmd" in # -e はダングリング symlink で false になるため、-L も見ないと # 出力先に残った古い source symlink が保護をすり抜けて上書きされる if [[ -e "$out" || -L "$out" ]]; then - if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF -- "$rel" "$dist/.hawk-dist"; then + # marker は src_root と rel のペアで記録する。rel だけで照合すると、別ディレクトリの + # 別 source が同じ basename の rel (例: 複数 app の main.awk) を偶然共有したとき、 + # 後勝ちの desugar が前の source の成果物を無警告で上書きしてしまう。 + # dist パス自体の一意化は dist レイアウト規約の再設計 (#119) 側の課題であり、 + # ここでは異なる source からの同名 rel 上書きを拒否するだけに留める + if [[ ! -f "$dist/.hawk-dist" ]] || ! grep -qxF -- "$src_root"$'\t'"$rel" "$dist/.hawk-dist"; then echo "[hawk-libs] desugar failed: refusing to overwrite existing file in non-dist directory: $out (remove it or the whole directory if it is a stale dist)" >&2 exit 1 fi fi mkdir -p "$(dirname "$out")" + # dist 配下のディレクトリが外部への symlink に差し替わっていると、mkdir -p はそれを + # 素通りし、後続の mv は symlink 先 (dist の外) に publish してしまう。 + # 出力先の親を実パスまで解決して dist の実パス配下であることを都度確認する + out_parent_phys="$(cd "$(dirname "$out")" && pwd -P)" + if [[ "$out_parent_phys/" != "$dist_phys/"* ]]; then + echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $out_parent_phys" >&2 + exit 1 + fi # 稼働中の hawk-serve が dist の成果物を再ロードしうるため、 # scratch (mktemp) 上で desugar と書き換えを済ませてから # mv でアトミックに配置する。scratch を mktemp にすることで @@ -237,19 +255,11 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: absolute include points inside project root (use a relative path): $inc" >&2 exit 1 fi - # hard link は readlink で辿れずパス名も外部のままなので、 - # パス比較では検出できない。訪問済みプロジェクトファイルとの - # device/inode 比較 (-ef) で同一実体を検出する - set -f - for prior in $_seen; do - if [[ "$inc" -ef "$src_root/$prior" ]]; then - set +f - rm -f "$tmp" - echo "[hawk-libs] desugar failed: absolute include is the same file as project file: $inc (same file as $prior)" >&2 - exit 1 - fi - done - set +f + # hard link は readlink で辿れずパス名も外部のままなので、パス比較では検出できない。 + # ただし staging 中の _seen には後続 sibling がまだ載っておらず、この時点で + # 照合すると hard link 元がまだ未訪問の後続 sibling を見逃す。closure 全体が + # 確定してからまとめて照合するため、ここではキューに積むだけに留める + _abs_ext+=("$inc") fi continue fi @@ -300,6 +310,20 @@ case "$subcmd" in entry_rel="$(basename "$src")" _desugar_one "$entry_rel" + # 絶対パス include の hard link 検査は closure 全体の _seen が確定してから行う + # (staging 中に照合すると未訪問の後続 sibling を見逃す)。publish の前に置き、 + # 検出時は何も publish しない + set -f + for _abs in "${_abs_ext[@]}"; do + for prior in $_seen; do + if [[ "$_abs" -ef "$src_root/$prior" ]]; then + set +f + echo "[hawk-libs] desugar failed: absolute include is the same file as project file: $_abs (same file as $prior)" >&2 + exit 1 + fi + done + done + set +f # closure 全体が成功してから一括 publish (深い子から、entry が最後) for ((i = 0; i < ${#_pub_tmp[@]}; i++)); do chmod 644 "${_pub_tmp[$i]}" @@ -313,7 +337,7 @@ case "$subcmd" in _scratch_arr+=("$_marker_tmp") { [[ -f "$dist/.hawk-dist" ]] && cat "$dist/.hawk-dist" - printf '%s\n' "${_pub_rel[@]}" + for _rel in "${_pub_rel[@]}"; do printf '%s\t%s\n' "$src_root" "$_rel"; done } | sort -u > "$_marker_tmp" mv "$_marker_tmp" "$dist/.hawk-dist" echo "$dist/$entry_rel" diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index a7ec3465..da33c1d8 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -264,6 +264,24 @@ st=$? set -e if [[ "$st" -eq 1 && "$err" == *"same file as"* ]]; then ok "abs_hardlink_include_to_project_rejected"; else ng "abs_hardlink_include_to_project_rejected" "exit=$st: $err"; fi +# --- 絶対パス hard link が、その absolute include より後で relative include される sibling を指す場合も exit 1 --- +abshard2="$TMP/abshard2" +outside3="$TMP/outside3" +mkdir -p "$abshard2" "$outside3" +printf 'function ah2_x() { return 1 }\n' > "$abshard2/x.awk" +ln "$abshard2/x.awk" "$outside3/alias.awk" +printf '@include "%s/alias.awk"\n@include "x.awk"\nBEGIN { print "abshard2" }\n' "$outside3" > "$abshard2/main.awk" +dist="$TMP/distah2" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$abshard2/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"same file as"* && ! -e "$dist/main.awk" && ! -e "$dist/x.awk" ]]; then + ok "abs_hardlink_to_later_sibling_rejected" +else + ng "abs_hardlink_to_later_sibling_rejected" "exit=$st: $err" +fi + # --- marker が書込不可なら staging 前に exit 1 (publish 後の更新失敗を防ぐ) --- # root (id -u == 0) では chmod a-w が -w 判定にも truncate 阻止にも効かず # exit=0 になってしまうため、root 実行時はこの2アサーションを SKIP する @@ -335,6 +353,27 @@ dist="$TMP/distrerun" HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null if HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null; then ok "rerun_over_marker_ok"; else ng "rerun_over_marker_ok" "exit != 0"; fi +# --- 異なる source の同名 basename entry (dist/main.awk が衝突) は marker に別 source +# として上書き拒否され、先に publish した成果物が生き残る --- +appsA="$TMP/appsA" +appsB="$TMP/appsB" +mkdir -p "$appsA" "$appsB" +printf 'BEGIN { print "from-a" }\n' > "$appsA/main.awk" +printf 'BEGIN { print "from-b" }\n' > "$appsB/main.awk" +dist="$TMP/distcs" +HAWK_DIST="$dist" "$LIBS" desugar "$appsA/main.awk" >/dev/null +before_hash=$(cksum < "$dist/main.awk") +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$appsB/main.awk" 2>&1 >/dev/null) +st=$? +set -e +after_hash=$(cksum < "$dist/main.awk") +if [[ "$st" -eq 1 && "$err" == *"refusing to overwrite"* && "$before_hash" == "$after_hash" ]]; then + ok "cross_source_same_basename_rejected" +else + ng "cross_source_same_basename_rejected" "exit=$st: $err" +fi + # --- marker は publish 済み rel のみ信頼し、同じ dist 配下の無関係な既存ファイルは # 引き続き上書き保護される (marker が空ファイルだと最初の成功後に # ディレクトリ全体を信頼してしまう問題の再現) --- @@ -455,5 +494,23 @@ set -e if [[ "$st" -eq 1 ]]; then ok "dangling_symlink_output_rejected"; else ng "dangling_symlink_output_rejected" "exit=$st: $err"; fi if [[ -L "$dist/solo.awk" && ! -e "$dist/solo.awk" ]]; then ok "dangling_symlink_output_untouched"; else ng "dangling_symlink_output_untouched"; fi +# --- marker 登録済みディレクトリが symlink に差し替わっていると、再 desugar は +# symlink 先 (dist の外) に publish せず exit 1 --- +dist="$TMP/distsymout" +HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" >/dev/null +outside_symout="$TMP/outside_symout" +mkdir -p "$outside_symout" +rm -rf "$dist/app" +ln -s "$outside_symout" "$dist/app" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$FIX/proj/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"escapes dist via symlink"* && ! -e "$outside_symout/page.awk" ]]; then + ok "symlinked_output_dir_rejected" +else + ng "symlinked_output_dir_rejected" "exit=$st: $err" +fi + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] From 5c5de88ecadf36ca6e504fcdde119b5149ca8307 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Tue, 14 Jul 2026 15:12:28 +0900 Subject: [PATCH 19/22] =?UTF-8?q?fix(hawk-libs):=20.hawk-dist=20=E9=85=8D?= =?UTF-8?q?=E4=B8=8B=E3=81=AE=20rel=20=E3=82=82=E4=BA=88=E7=B4=84=E5=90=8D?= =?UTF-8?q?=E3=81=A8=E3=81=97=E3=81=A6=E6=8B=92=E5=90=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー追撃(PR #118)への対応。 完全一致のみの検査だと ".hawk-dist/x.awk" のような rel が素通りし、 publish で dist/.hawk-dist/ がディレクトリとして作られる。その後の marker mv がディレクトリ内への移動になって exit 0 のまま marker が 壊れ、次回 desugar が必ず失敗する。第一成分が .hawk-dist の rel を すべて拒否する。 実装は agmsg 経由で Codex が担当、レビューは cc。 Co-Authored-By: Claude --- libexec/hawk-libs | 2 +- tests/unit/desugar/run.sh | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/libexec/hawk-libs b/libexec/hawk-libs index 4d0bd8fc..d5f1638d 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -115,7 +115,7 @@ case "$subcmd" in echo "[hawk-libs] desugar failed: include path contains spaces (unsupported): $rel" >&2 exit 1 ;; - .hawk-dist) + .hawk-dist|.hawk-dist/*) # dist ルートの marker と同じパスに publish されると、 # publish 後の marker 更新が成果物を書き潰すため予約名として拒否 echo "[hawk-libs] desugar failed: '.hawk-dist' is reserved for the dist marker: $rel" >&2 diff --git a/tests/unit/desugar/run.sh b/tests/unit/desugar/run.sh index da33c1d8..cab52c5f 100755 --- a/tests/unit/desugar/run.sh +++ b/tests/unit/desugar/run.sh @@ -226,6 +226,19 @@ st=$? set -e if [[ "$st" -eq 1 && "$err" == *"reserved for the dist marker"* ]]; then ok "marker_name_include_rejected"; else ng "marker_name_include_rejected" "exit=$st: $err"; fi +resns="$TMP/resname" +mkdir -p "$resns/.hawk-dist" +cp "$FIX/resname/main.awk" "$resns/main.awk" +printf 'function rn_x() { return 1 }\n' > "$resns/.hawk-dist/x.awk" +sed -i.bak 's/\.hawk-dist"/.hawk-dist\/x.awk"/' "$resns/main.awk" +rm "$resns/main.awk.bak" +dist="$TMP/distns" +set +e +err=$(HAWK_DIST="$dist" "$LIBS" desugar "$resns/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && "$err" == *"reserved for the dist marker"* && ! -e "$dist/main.awk" && ! -e "$dist/.hawk-dist" ]]; then ok "marker_namespace_include_rejected"; else ng "marker_namespace_include_rejected" "exit=$st: $err"; fi + # --- プロジェクト内を指す絶対パス include は exit 1、外部の絶対パスは素通り --- absinc="$TMP/absinc" mkdir -p "$absinc" From 7e5211298eeece64eb8e3991a026360d8313827c Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Tue, 14 Jul 2026 20:18:02 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix(cli):=20check/emit=20=E3=82=92?= =?UTF-8?q?=E9=9A=94=E9=9B=A2=E3=81=97=E3=81=9F=E4=B8=80=E6=99=82=20dist?= =?UTF-8?q?=20=E3=81=AB=E5=87=BA=E5=8A=9B=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex レビュー指摘(PR #118)への対応。 check --strict / emit --strict は desugar が dist を publish した後に gawk --sandbox の strict 検証を実行する。検証失敗でも永続 dist は 更新済みになり、同じ dist を使う稼働中の hawk-serve が「失敗した check の成果物」を worker 再起動時に読んでしまう。 check は検証、emit は出力が目的で永続 dist を必要としないため、 呼び出し元の HAWK_DIST も使わず mktemp の隔離 dist に出力し、 終了時に削除する。永続 dist を温めたい場合は hawk-libs desugar を 直接使う。 Co-Authored-By: Claude --- libexec/hawk-check | 7 +++++++ libexec/hawk-emit | 7 +++++++ tests/unit/cli/run.sh | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/libexec/hawk-check b/libexec/hawk-check index d311308a..92ff7510 100755 --- a/libexec/hawk-check +++ b/libexec/hawk-check @@ -27,6 +27,13 @@ if [[ -f .env ]]; then set +a fi +# check は検証のみが目的。永続 dist に publish した後で strict 検証が走ると、 +# 検証失敗でも成果物が更新済みになり、稼働中の hawk-serve が失敗した check の +# 出力を読んでしまう。呼び出し元の HAWK_DIST も使わず隔離した一時 dist に出す +HAWK_DIST="$(mktemp -d /tmp/hawk-check.XXXXXX)" +export HAWK_DIST +trap 'rm -rf "$HAWK_DIST"' EXIT + APP_AWK="$("$LIBS" desugar "$APP")" if [[ "$STRICT" -eq 1 ]]; then diff --git a/libexec/hawk-emit b/libexec/hawk-emit index 00b513ea..d5941919 100755 --- a/libexec/hawk-emit +++ b/libexec/hawk-emit @@ -27,6 +27,13 @@ if [[ -f .env ]]; then set +a fi +# emit は desugar 結果の出力のみが目的。永続 dist に publish した後で strict 検証が +# 走ると、検証失敗でも成果物が更新済みになり、稼働中の hawk-serve が失敗した emit の +# 出力を読んでしまう。呼び出し元の HAWK_DIST も使わず隔離した一時 dist に出す +HAWK_DIST="$(mktemp -d /tmp/hawk-emit.XXXXXX)" +export HAWK_DIST +trap 'rm -rf "$HAWK_DIST"' EXIT + APP_AWK="$("$LIBS" desugar "$APP")" if [[ "$STRICT" -eq 1 ]]; then diff --git a/tests/unit/cli/run.sh b/tests/unit/cli/run.sh index 1af30ac3..f984a313 100755 --- a/tests/unit/cli/run.sh +++ b/tests/unit/cli/run.sh @@ -60,5 +60,23 @@ else FAIL=$((FAIL+1)) fi +# check/emit は隔離した一時 dist を使い、cwd の永続 dist を作らない・更新しない +HAWK_ABS="$(pwd)/bin/hawk" +VALID_ABS="$(pwd)/$VALID" +work=$(mktemp -d) +( cd "$work" && HAWK_NO_LIBS=1 "$HAWK_ABS" check --strict "$VALID_ABS" >/dev/null 2>&1 ) +if [[ ! -e "$work/dist" ]]; then + printf " PASS: check_leaves_no_cwd_dist\n"; PASS=$((PASS+1)) +else + printf " FAIL: check_leaves_no_cwd_dist (dist/ created)\n"; FAIL=$((FAIL+1)) +fi +( cd "$work" && HAWK_NO_LIBS=1 "$HAWK_ABS" emit "$VALID_ABS" >/dev/null 2>&1 ) +if [[ ! -e "$work/dist" ]]; then + printf " PASS: emit_leaves_no_cwd_dist\n"; PASS=$((PASS+1)) +else + printf " FAIL: emit_leaves_no_cwd_dist (dist/ created)\n"; FAIL=$((FAIL+1)) +fi +rm -rf "$work" + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] From 6f40968a0397b6bbacfc091122f6fad4b2602335 Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Wed, 15 Jul 2026 10:19:13 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20emit=20=E5=87=BA=E5=8A=9B=E3=81=A8?= =?UTF-8?q?=20dist=20=E8=A6=AA=E3=82=92=E5=AE=89=E5=85=A8=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一時 dist の include を展開して emit 結果を単独実行可能にし、dist 内の symlink 親を mkdir 前に拒否して外部への副作用を防ぐ。 --- libexec/hawk-emit | 24 +++++++++++++++++++++++- libexec/hawk-libs | 28 ++++++++++++++++++++++++++-- tests/unit/cli/run.sh | 19 +++++++++++++++++++ tests/unit/desugar/run.sh | 19 +++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/libexec/hawk-emit b/libexec/hawk-emit index d5941919..240f04d2 100755 --- a/libexec/hawk-emit +++ b/libexec/hawk-emit @@ -43,4 +43,26 @@ if [[ "$STRICT" -eq 1 ]]; then fi fi -cat "$APP_AWK" +# desugar はローカル include を隔離 dist 内の絶対パスへ書き換えるが、 +# emit の stdout は一時 dist を trap で削除した後も単独で実行できる必要がある。 +# dist 内の include だけを再帰展開し、外部 include は gawk に任せてそのまま残す。 +declare -A EMIT_SEEN=() +EMIT_INCLUDE_RE='^[[:space:]]*@include[[:space:]]+"([^"]+)"' + +emit_file() { + local file="$1" line inc + [[ -n "${EMIT_SEEN["$file"]+seen}" ]] && return 0 + EMIT_SEEN["$file"]=1 + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ $EMIT_INCLUDE_RE ]]; then + inc="${BASH_REMATCH[1]}" + if [[ "$inc" == "$HAWK_DIST"/* && -f "$inc" ]]; then + emit_file "$inc" + continue + fi + fi + printf '%s\n' "$line" + done < "$file" +} + +emit_file "$APP_AWK" diff --git a/libexec/hawk-libs b/libexec/hawk-libs index d5f1638d..b702aebb 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -102,7 +102,7 @@ case "$subcmd" in # rel: src_root からの相対パス。desugar して dist/rel に出力し、 # src_root 相対で実在する @include を dist 接頭に書き換えて再帰する。 _desugar_one() { - local rel="$1" in out tmp inc incs prior + local rel="$1" in out out_parent out_rel probe part tmp inc incs prior case "$_seen" in *" $rel "*) return 0 ;; esac case "$rel" in ../*|*/../*) @@ -170,7 +170,31 @@ case "$subcmd" in exit 1 fi fi - mkdir -p "$(dirname "$out")" + # mkdir -p は既存の symlink を辿るため、先に dist 配下の既存コンポーネントを + # 検査する。例えば dist/app -> 外部 の状態で app/sub を作ると、dist 外に + # sub/ が作られてから下の実パス検査に到達してしまう。 + out_parent="$(dirname "$out")" + probe="$dist" + if [[ "$out_parent" == "$dist"/* ]]; then + out_rel="${out_parent#"$dist"/}" + while [[ -n "$out_rel" ]]; do + if [[ "$out_rel" == */* ]]; then + part="${out_rel%%/*}" + out_rel="${out_rel#*/}" + else + part="$out_rel" + out_rel= + fi + [[ -n "$part" ]] || continue + probe="$probe/$part" + if [[ -L "$probe" ]]; then + echo "[hawk-libs] desugar failed: output directory escapes dist via symlink: $probe" >&2 + exit 1 + fi + [[ -e "$probe" ]] || break + done + fi + mkdir -p "$out_parent" # dist 配下のディレクトリが外部への symlink に差し替わっていると、mkdir -p はそれを # 素通りし、後続の mv は symlink 先 (dist の外) に publish してしまう。 # 出力先の親を実パスまで解決して dist の実パス配下であることを都度確認する diff --git a/tests/unit/cli/run.sh b/tests/unit/cli/run.sh index f984a313..1414f7b2 100755 --- a/tests/unit/cli/run.sh +++ b/tests/unit/cli/run.sh @@ -60,6 +60,25 @@ else FAIL=$((FAIL+1)) fi +# emit は隔離 dist 内の複数ファイル include を stdout だけで実行できる形に展開する +emit_multi=$(mktemp -d) +mkdir -p "$emit_multi/app" +printf '@include "app/part.awk"\n@include "app/part.awk"\nBEGIN { print app_message() }\n' > "$emit_multi/main.awk" +printf 'function app_message() { return "multifile-ok" }\n' > "$emit_multi/app/part.awk" +emit_multi_out=$(HAWK_NO_LIBS=1 "$HAWK" emit "$emit_multi/main.awk" 2>/dev/null) +printf '%s\n' "$emit_multi_out" > "$emit_multi/emitted.awk" +if ! printf '%s\n' "$emit_multi_out" | grep -q '@include'; then + run_out=$(gawk -f "$emit_multi/emitted.awk" "$symlink_parent_src/main.awk" +printf 'function symlink_parent_x() { return 1 }\n' > "$symlink_parent_src/app/sub/x.awk" +symlink_parent_dist="$TMP/symlink-parent-dist" +symlink_parent_outside="$TMP/symlink-parent-outside" +mkdir -p "$symlink_parent_dist" "$symlink_parent_outside" +ln -s "$symlink_parent_outside" "$symlink_parent_dist/app" +set +e +err=$(HAWK_DIST="$symlink_parent_dist" "$LIBS" desugar "$symlink_parent_src/main.awk" 2>&1 >/dev/null) +st=$? +set -e +if [[ "$st" -eq 1 && ! -e "$symlink_parent_outside/sub" ]]; then + ok "symlinked_parent_no_external_mkdir" +else + ng "symlinked_parent_no_external_mkdir" "exit=$st, outside_sub=$([[ -e "$symlink_parent_outside/sub" ]] && echo yes || echo no): $err" +fi + printf "\n%d passed, %d failed\n" "$PASS" "$FAIL" [[ $FAIL -eq 0 ]] From 238fb547cf390e866fd02708ba9a774d19f7761c Mon Sep 17 00:00:00 2001 From: redpeacock78 Date: Wed, 15 Jul 2026 15:47:42 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix:=20emit=20=E3=81=AE=20Bash=203.2=20?= =?UTF-8?q?=E4=BA=92=E6=8F=9B=E6=80=A7=E3=82=92=E6=88=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 単発 emit に supervisor と同じ Bash 4 要件を波及させないため、include の訪問済み集合を連想配列から既存の文字列照合へ戻す。 --- libexec/hawk-emit | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libexec/hawk-emit b/libexec/hawk-emit index 240f04d2..6b3463ed 100755 --- a/libexec/hawk-emit +++ b/libexec/hawk-emit @@ -46,13 +46,13 @@ fi # desugar はローカル include を隔離 dist 内の絶対パスへ書き換えるが、 # emit の stdout は一時 dist を trap で削除した後も単独で実行できる必要がある。 # dist 内の include だけを再帰展開し、外部 include は gawk に任せてそのまま残す。 -declare -A EMIT_SEEN=() +EMIT_SEEN=" " EMIT_INCLUDE_RE='^[[:space:]]*@include[[:space:]]+"([^"]+)"' emit_file() { local file="$1" line inc - [[ -n "${EMIT_SEEN["$file"]+seen}" ]] && return 0 - EMIT_SEEN["$file"]=1 + case "$EMIT_SEEN" in *" $file "*) return 0 ;; esac + EMIT_SEEN="$EMIT_SEEN$file " while IFS= read -r line || [[ -n "$line" ]]; do if [[ "$line" =~ $EMIT_INCLUDE_RE ]]; then inc="${BASH_REMATCH[1]}"