#!/usr/bin/env bash # # pre-push:**推送前**拦住"不该进版本库的文件"。 # # ★ 为什么要有这个钩子,而不是只靠 `criteria-hygiene` 里那条判据: # 那条判据读的是 **index**(`git ls-files`),它守的是"**不会被再加回来**", # 不是"**不会被推出去**"。而 blob 恰恰躺在**未推送的提交**里 —— # 也就是说 `git rm --cached` 之后,东西已经从 index 里消失了, # 可是**历史里还有**,下一次 `git push` 会连它一起发出去,而**没有任何东西会红**。 # ⇒ "先 ignore + `rm --cached`、再 push"这条顺序**只活在提交信息里**, # 而"只活在文字里的规则等于没有规则"正是我们加那条判据的理由。 # 判据是**泄露之后**响的闹钟;这个钩子是**不让它泄露**。两件事都要有。 # # 这个钩子由 `deploy/install.sh --git-hooks` 装(`core.hooksPath` 指向 `.githooks`), # 所以它**跟着仓库走**:换一台机器 clone 下来,装一次就都装上了。 # # 退出码:0 放行,1 拦下(git 会中止 push)。**绝不返回 2** —— # 钩子里非 0 一律中止推送,所以"钩子自己坏了"与"真的有违规"都会拦下来, # 这是**故意的方向**:宁可推不出去,也不要静默推出去。 set -uo pipefail # 不许进版本库的路径(**加新路径就加在这里**,别写进判据里到处复制) FORBIDDEN=( "client/harmony/entry/src/main/resources/rawfile/agconnect-services.json" ) # 生效范围:推送的所有 ref 区间。钩子从 stdin 收到: # # 远端 sha 全 0 = 新分支 ⇒ 要查**整个可达历史**。 zero="0000000000000000000000000000000000000000" declare -a RANGES=() while read -r _lref lsha _rref rsha; do # ★ **删除**用**全零** sha 表示,不是空串(原来是 `[ -z "${lsha:-}" ] && continue`)。 # 少了这一支的后果:删除时 lsha=全零、rsha=旧值 ⇒ 走 else ⇒ 拼出 # `旧sha..0000…` ⇒ `git log` 解析不了 ⇒ **exit 1 中止**。 # 实测(真 bare 仓): # $ git push /tmp/bare-del.git --delete tmp # pre-push: 无法解析范围 '6702cc2f…..0000000000000000000000000000000000000000' —— 中止推送 # # 为什么这不是小事:**删除不发布任何东西**,所以这是**纯误红**; # 而它恰恰因为"宁可推不出去"这条**正确的**方向而不可见 —— 看起来就像设计好的安全行为。 # 同族形状:**一道闸消费了不属于它管辖的东西**("到期闸把汇总一起吃掉"也是这个)。 # 附带效果:`install.sh --check` 会一直说"钩子已接",而实际上删分支/删 tag 全被挡。 # # ★ 我第一步只验了"会泄露的范围 ⇒ 1、已推过的范围 ⇒ 0"—— # **两个方向都没覆盖"没有东西被发布"这一支**,而它正是坏的那一支。 # 和"只判下界"是同一条:**把边界少判了一侧**。 if [ -z "${lsha:-}" ] || [ "$lsha" = "$zero" ]; then continue # 没有东西被发布(删除 / 空 ref)—— 放行 fi if [ "$rsha" = "$zero" ]; then # 新分支/新 tag:从根开始查(`--not --remotes` 会漏掉已被别的远端包含的情况) RANGES+=("$lsha") else RANGES+=("${rsha}..${lsha}") fi done if [ "${#RANGES[@]}" -eq 0 ]; then exit 0 # 没有任何 ref 要推(例如只删远端分支)—— 放行 fi problems=0 for path in "${FORBIDDEN[@]}"; do for range in "${RANGES[@]}"; do # `--all` 覆盖"这次推送带出去的所有提交里,这个路径是否被改动过" if ! hits="$(git log --format=' %h %s' "$range" -- "$path" 2>/dev/null)"; then echo "pre-push: 无法解析范围 '$range' —— 中止推送(宁可推不出去,也不要盲推)" >&2 exit 1 fi if [ -n "$hits" ]; then problems=1 { echo "" echo "✗ 这次推送会把 **$path** 的历史一起发出去。" echo " 它是 AGC 客户端凭证,而本仓的镜像是**匿名可 clone 的公开项目**。" echo " 涉及这些提交:" echo "$hits" echo "" echo " 怎么处置(按顺序):" echo " 1) 如果这些提交**已经**推上去过:按\"已泄露\"处理 —— 去 AGC 轮换," echo " 并且**先**在本地把这些提交改掉(rebase/filter-repo),再推。" echo " 2) 如果还没推过:把涉及它的提交从待推范围里摘掉,或者先把历史里的 blob 清掉。" echo " 3) 确认它**不在**要推的范围里之后,再 push。" echo "" echo " (这条钩子的存在理由:判据读的是 index,只证明\"不会再被加回来\";" echo " 而 blob 还躺在未推送的提交里,只有推送前拦得住。)" } >&2 fi done done # ── 第二道:构建产物里内嵌 AGC 真身(2026-09-28 新增)──────────────────── # # 为什么不能只靠上面的路径清单:**hap/ipa 之类的打包产物会把 # `agconnect-services.json` 原封不动打进去**,而它在产物里只是一个 zip 条目。 # 逐条列举路径追不上产物形态(下次换个构建目标又漏一次)。 # # 实测(`git ls-files` 抓到的):两个 36MB 的 .hap,内含 # `resources/rawfile/agconnect-services.json`,其中 client.client_id / # code.code1..4(4 个 32 位十六进制)/ oauth_client.client_id 全是真值。 # # 口径:只看**本次推送范围内被新增/修改的二进制产物**,不扫全历史 —— # ① 全历史扫描在大仓上很慢,而这道闸的目标是"拦住下一次手滑"; # ② 已经推出去的那部分由 `criteria-hygiene` 那条报警负责(泄露之后响的闹钟)。 # # ★ 这里用 `git log --diff-filter=A --name-only` 而不是 `git diff`(2026-09-28 实测踩坑): # `RANGES` 里的元素是「`rsha..lsha`」或(新分支时)**单个 `lsha`**。 # 把单个 sha 传给 `git diff` 并不会得到"这个范围里新增的文件"—— 它是 # 「工作区 vs 该 sha」的差异,**在干净工作区上恒为空** ⇒ 判据**静默失效**, # 而失效的判据与通过的长得一模一样。(第一版就这么写的,实测:含凭证的 hap 被放行, # push exit=0。) # `git log` 才是"这些提交里动过什么"的那个读数,与第一道检查同源。 # # 判据形态:**新增或修改的构建产物** + 在该提交的文件内容里搜 # `agconnect-services.json` 这个**条目名**。用条目名而不是凭证值本身, # 因为值会变而条目名稳定,且搜值需要把凭证读进内存。 for range in "${RANGES[@]}"; do if ! bins="$(git log --diff-filter=AM --name-only --format= "$range" -- '*.hap' '*.app' '*.ipa' 2>/dev/null)"; then echo "pre-push: 无法解析范围 '$range'(查构建产物)—— 中止推送" >&2 exit 1 fi bins="$(printf '%s\n' "$bins" | sort -u | grep -v '^$')" [ -z "$bins" ] && continue while IFS= read -r b; do [ -n "$b" ] || continue # ★★ 取 blob 必须先把范围解析成**单个 commit**,再 `ls-tree`(2026-09-28 实测两次踩坑): # # ① `git rev-parse "a..b:path"` 输出**两行**(blob sha + `^a`), # 赋进变量是多行值,`cat-file` 失败而被 `||` 兼掉 ⇒ 静默放行。 # (手工喂真实待推范围给钩子,exit=0;而同一范围内 `git log` 明确列出了两个 hap。 # 判据"看起来在检查"而实际没执行 —— 与本仓 python-probe-shadowing 那笔同族。) # ② 改用 `ls-tree` **也报错**:它不接受范围 # (`fatal: Not a valid object name a..b`)。 # # ⇒ 正确形状:先取范围内**最后一个** commit(`git rev-list --max-count=1`), # 再 `ls-tree <那个 commit>`。第一道检查用的是 `git log`(天生吃范围), # 所以它没踩这个坑 —— **同一条数据,git 子命令的输入契约不一样**。 tip="$(git rev-list --max-count=1 "$range" 2>/dev/null | head -1)" if [ -z "$tip" ]; then echo "pre-push: 范围 '$range' 解析不出 commit(查构建产物)—— 中止推送" >&2 exit 1 fi blob_sha="$(git ls-tree "$tip" -- "$b" 2>/dev/null | awk 'NR==1{print $3}')" if [ -z "$blob_sha" ]; then # 该 tip 上没有这个文件(可能在范围内被删了)—— 读不到就不当违规,但**说明**, # 因为"没检查到"与"没问题"不是一回事。 echo "pre-push: 读不到 $b 在待推范围里的内容(可能已被删除)—— 未据此判红" >&2 continue fi # ★★ **必须扫全文件**(2026-09-28 实测修正):原先只取头尾各 4MB, # 理由是"zip 的中央目录在尾部"。实测本仓真 hap 里那个条目在 # **byte 28,239,718 与 35,958,749**(文件共 35MB)—— 头 4MB 扫不到、 # 尾 4MB 也只勉强够到第二处。**第一版会把这个含真凭证的 hap 放过去。** # ⇒ 改全量流式扫(grep -a 直接吃管道,不进内存)。 # # 代价:每个 35MB 产物要读一遍。推送路径上、非热路径,可以接受。 # 真的嫌慢就改成 `grep -a -m1`(找到一个就停),语义不变且更快。 # ★★★ **不能用 `grep -q` 接管道**(2026-09-28 实测,方向与直觉相反): # `grep -q` 找到就**立即退出**,于是上游 `git cat-file` 在写管道时收到 SIGPIPE # ⇒ 它的退出码是 **141**。而本钩子开头有 `set -uo pipefail`(`pipefail` 把管道 # 判成取**各段非零的合取**)⇒ 整条管道变成 141 ⇒ `if` 判为**假**。 # # 实测读数:命中时 `PIPESTATUS=141 0`(cat-file=141, grep=0)⇒ `if` 不成立。 # **即"找到了凭证"被读成"没找到"** —— 而失效方向恰好是**放行**, # 与"宁可推不出去"的正确方向相反,是最坏的那种失效。 # # ⇒ 不用 `-q`:让 grep 读完整条流、正常退出 0,最后一个命令自然是 grep 的码。 # (代价是扫完整个 35MB 而不是命中即停;推送路径上可接受。) if git cat-file -p "$blob_sha" 2>/dev/null | grep -a 'agconnect-services\.json' >/dev/null; then problems=1 { echo "" echo "✗ 这次推送会带上构建产物 **$b**,而它内嵌了 agconnect-services.json。" echo " 那是 AGC 客户端凭证(api_key 信封),而本仓镜像是**匿名可 clone 的公开项目**。" echo "" echo " 怎么处置:" echo " 1) 构建前把 AGC 真身从打包输入里去掉(用 .example.json 占位)," echo " 或在构建产物生成后立刻 git rm --cached 掉。" echo " 2) 已经进了要推的提交:用 filter-repo / rebase 把该 blob 清掉," echo " 确认这些提交**没有**被推过;推过了就按已泄露处理 —— 去 AGC 轮换。" echo "" echo " (这一格是**按内容**查的,不是按路径 —— 产物名会变,凭证的容器也会变。)" } >&2 fi done <<<"$bins" done exit "$problems"