两个 36MB 的 .hap 被 f51c9c8(一个标题为「workspace 谓词抽成共享构造器」的
重构提交)顺手带进版本库,而它们**内嵌 AGC 配置真身**。
## 实测确认(不是推测)
用 python zipfile 打开两个 hap,逐字段核对形状(不打印值):
resources/rawfile/agconnect-services.json
client.client_id HEX len=19
code.code1..code4 HEX len=32 ×4 ← api_key 信封
oauth_client.client_id HEX len=19
app_info.app_id HEX len=19
`git merge-base --is-ancestor f51c9c8 origin-https/main` 判否
⇒ **仅本地、尚未推送**(本仓镜像是 public / 匿名可 clone)。
## 为什么只 rm --cached 不够
`git rm --cached` 只把文件移出 index,**blob 仍躺在未推送的提交里** ——
下一次 `git push` 会连它一起发出去。所以必须同时堵"下一次"。
## 改法
① .gitignore 加 `*.hap` / `*.app` / `*.ipa`
② .githooks/pre-push 加第二道闸:**按内容**查,不按路径
—— 原先只按路径拦 agconnect-services.json,而 hap 里它只是
一个 zip 条目,逐条列举路径追不上产物形态。
判据形态:本次推送范围内**新增或修改**的构建产物(`*.hap/*.app/*.ipa`)
→ 内容里出现 `agconnect-services.json` 这个**条目名** ⇒ 拦。
用条目名而非凭证值:值会变而条目名稳定,且搜值需把凭证读进内存。
## ★ 这一格踩了三个坑,每个都让闸**静默放行**(都实测过)
① `git diff "a..b"` 取不到"该范围新增的文件" —— 它的语义是
「工作区 vs b」,**干净工作区上恒为空**。第一版这么写,
实测含真凭证的 hap 被放行、push exit=0。
⇒ 改 `git log --diff-filter=AM --name-only`(天生吃范围)。
② `git rev-parse "a..b:path"` 输出**两行**(blob sha + `^a`),
赋进变量是多行值 ⇒ 后续 `cat-file` 失败 ⇒ 被 `|| blob_sha=""`
兼掉 ⇒ 静默跳过。
`git ls-tree "a..b"` 也报错(不接受范围)。
⇒ 正确形状:rev-list 取 tip → ls-tree <tip> 取 blob。
③ ★★ `grep -q` 接管道 + `set -o pipefail`:grep 命中即退出 ⇒ 上游
`git cat-file` 收 SIGPIPE 退 **141** ⇒ pipefail 取各段合取 ⇒
整条管道 141 ⇒ `if` 判假。**"找到凭证"被读成"没找到"。**
实测 PIPESTATUS=141 0。
失效方向恰好是**放行**,与本钩子"宁可推不出去"相反。
⇒ 去掉 `-q`,让 grep 读完整条流(最后一段自然是它的码)。
## 验证(四态,都实测过)
含真凭证的 hap ⇒ 拦,裸仓没收到 commit
干净的 hap ⇒ 放行(只有 example.json)
AGC json 路径 ⇒ 仍拦(第一道没坏)
删分支 ⇒ 不误拦
commit-hygiene ⇒ 绿
190 lines
11 KiB
Bash
Executable File
190 lines
11 KiB
Bash
Executable File
#!/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 收到:
|
||
# <local ref> <local sha> <remote ref> <remote sha>
|
||
# 远端 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"
|