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/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/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/libexec/hawk-check b/libexec/hawk-check index 34f510ff..92ff7510 100755 --- a/libexec/hawk-check +++ b/libexec/hawk-check @@ -27,8 +27,14 @@ 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")" -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..6b3463ed 100755 --- a/libexec/hawk-emit +++ b/libexec/hawk-emit @@ -27,8 +27,14 @@ 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")" -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 @@ -37,4 +43,26 @@ if [[ "$STRICT" -eq 1 ]]; then fi fi -cat "$APP_AWK" +# desugar はローカル include を隔離 dist 内の絶対パスへ書き換えるが、 +# emit の stdout は一時 dist を trap で削除した後も単独で実行できる必要がある。 +# dist 内の include だけを再帰展開し、外部 include は gawk に任せてそのまま残す。 +EMIT_SEEN=" " +EMIT_INCLUDE_RE='^[[:space:]]*@include[[:space:]]+"([^"]+)"' + +emit_file() { + local file="$1" line inc + 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]}" + 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 00289907..b702aebb 100755 --- a/libexec/hawk-libs +++ b/libexec/hawk-libs @@ -52,13 +52,319 @@ 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" + dist="${HAWK_DIST:-dist}" + mkdir -p "$dist" + # 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 - echo "$tmp" + 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 + # 書込可だが読込不可だと、末尾の 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)" + dist_phys="$(cd "$dist" && pwd -P)" + _seen=" " + # 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=() + _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 接頭に書き換えて再帰する。 + _desugar_one() { + 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 + ../*|*/../*) + 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 + ;; + .hawk-dist|.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 排除が効かず定義が二重実行される。 + # 別名での 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" + # -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 + # 出力先がディレクトリだと mv がその中に移動してしまい exit 0 のまま壊れる + if [[ -d "$out" ]]; then + echo "[hawk-libs] desugar failed: output path is a directory: $out" >&2 + exit 1 + fi + # 過去の desugar が publish した rel パスであることを .hawk-dist マーカーで確認する。 + # マーカーが無い、または rel が記録されていない既存ファイルは source とみなし + # 上書きしない (HAWK_DIST が source 木の中を指すと -ef では検出できない別ファイルを + # 潰すため。marker を空ファイルにするだけだと最初の成功後はディレクトリ全体を + # 信頼してしまい、無関係な source ファイルまで上書き対象になる) + # -e はダングリング symlink で false になるため、-L も見ないと + # 出力先に残った古い source symlink が保護をすり抜けて上書きされる + if [[ -e "$out" || -L "$out" ]]; 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 は既存の 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 の実パス配下であることを都度確認する + 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 にすることで + # 固定名 ($out.tmp) が実在する成果物と衝突する問題も避ける。 + tmp="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" + # 途中失敗時の scratch 残留を防ぐ (mv 済みの名前への rm -f は no-op) + _scratch_arr+=("$tmp") + # dsl/desugar.awk 自身が @include "dsl/util.awk" 等の相対パスを持ち、 + # gawk は '/' を含む @include をカレントディレクトリ相対で解決するため、 + # HAWK_LIB (リポジトリルート) に cd してから実行する。 + 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 + # @include パスの ./ を正規化する。生テキスト比較の + # entry 循環検査・_seen 重複排除・書き換えの対応表が + # "./a.awk" と "a.awk" を同一視できるようにするため。 + # 正規化は src_root 相対で実在するファイルに限る + # ("./x.awk" はローカル解決のみだが "x.awk" は AWKPATH に落ちるため、 + # 実在しないパスを書き換えると挙動が変わってしまう) + tmp2="$(mktemp "$(dirname "$out")/.hawk-desugar.XXXXXX")" + _scratch_arr+=("$tmp2") + 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 + } + ' "$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 + # 絶対パス include がプロジェクト内のファイルを指すと、mirror 後も + # 原本 (未 desugar) を直接ロードして定義が二重実行される。 + # プロジェクト内は相対パスに限定し、外部の絶対パス (共有ライブラリ等) は + # 従来どおり素通りで残す + if [[ "$inc" == /* ]]; then + 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 + # hard link は readlink で辿れずパス名も外部のままなので、パス比較では検出できない。 + # ただし staging 中の _seen には後続 sibling がまだ載っておらず、この時点で + # 照合すると hard link 元がまだ未訪問の後続 sibling を見逃す。closure 全体が + # 確定してからまとめて照合するため、ここではキューに積むだけに留める + _abs_ext+=("$inc") + fi + continue + fi + # entry への back-edge は gawk が実行時に + # "cannot include `X' and use it as a program file" で fatal になるため + # desugar 時点で拒否する (entry を経由しない循環は gawk が重複 include を + # スキップするので許容) + # -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 + 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")" + _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 } + { + if (match($0, /^([[:space:]]*@include[[:space:]]+")([^"]+)(".*)$/, m) && (m[2] in L)) + print m[1] dist "/" m[2] m[3] + else + print + } + ' "$tmp" > "$tmp2"; then + rm -f "$tmp" "$tmp2" + echo "[hawk-libs] desugar failed: include rewrite failed: $in" >&2 + exit 1 + fi + mv "$tmp2" "$tmp" + fi + # 子を先に desugar し、closure 全体が成功するまで publish しない。 + # 途中失敗時に新旧の成果物が dist に混在するのを防ぐ + if [[ -n "$incs" ]]; then + while IFS= read -r inc; do + [[ -n "$inc" ]] && _desugar_one "$inc" + done <<< "$incs" + fi + _pub_tmp+=("$tmp") + _pub_out+=("$out") + _pub_rel+=("$rel") + return 0 + } + + 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]}" + mv "${_pub_tmp[$i]}" "${_pub_out[$i]}" + done + # 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" + 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" ;; *) echo "[hawk-libs] unknown subcommand: $subcmd" >&2 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 diff --git a/tests/unit/cli/run.sh b/tests/unit/cli/run.sh index 1af30ac3..1414f7b2 100755 --- a/tests/unit/cli/run.sh +++ b/tests/unit/cli/run.sh @@ -60,5 +60,42 @@ 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" /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 ]] 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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 new file mode 100755 index 00000000..3c115cb6 --- /dev/null +++ b/tests/unit/desugar/run.sh @@ -0,0 +1,548 @@ +#!/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)); } +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 + +# --- 再帰 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" &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" 形式の 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 + +# --- 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 + 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 + 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; 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 +if [[ ! -e "$dist/main.awk" ]]; then ok "parent_not_published_on_child_failure"; else ng "parent_not_published_on_child_failure" "dist/main.awk exists"; fi +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 +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 が 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 + +# --- 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 + +# --- 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 + 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 + +# --- 同一実体を別名で 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 +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" +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 + +# --- 空白入り 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 + +# --- ".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 + +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" +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 + +# --- プロジェクト内のファイルへの 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 + +# --- 絶対パス 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 する +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" +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" +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" +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 + +# --- 異なる 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 が空ファイルだと最初の成功後に +# ディレクトリ全体を信頼してしまう問題の再現) --- +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" +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 + +# --- 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 + +# --- 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 + +# --- 出力先の親 symlink は mkdir -p より前に拒否し、外部にディレクトリを作らない --- +symlink_parent_src="$TMP/symlink-parent-src" +mkdir -p "$symlink_parent_src/app/sub" +printf '@include "app/sub/x.awk"\nBEGIN { print "symlink-parent" }\n' > "$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 ]] 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") +}