Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Cloudflare Worker(本仓库 worker/,静态页面同域托管)
│ 因此包名被占或版本倒退在传字节之前就被挡住
│ ⑤ .deb 流式转发进 packages 仓库 staging Release(BOT_TOKEN),全程不落
│ 内存,上限由 Cloudflare 请求体限制而非 Worker 的 128 MB 内存决定
│ ⑥ repository_dispatch: web-submission
│ ⑥ repository_dispatch: web-submission(只改商店信息时为 web-listing-update)
▼
CardputerZero/packages Actions(`packages-workflows/` 是这些 workflow 的源)
│ ⑦ dpkg-deb 权威校验:完整性 / control / .desktop / setuid / 设备文件 /
Expand Down Expand Up @@ -62,14 +62,56 @@ fork 用自己改写的规则给自己打分。
- 管理员:`wrangler.toml` 的 `ADMIN_LOGINS`(逗号分隔的 GitHub 登录名)
可管理任意包。
- 相同版本再次提交 → 前端与服务端都会提示"版本已存在,请提升版本号"。
- 只差 `~` / `.` / `:` 的两个版本(如 `1.0~rc1` 与 `1.0.rc1`)在 Release 里是
同一个文件名,后一个会被拒绝;已发布的发布清单(`*.deb.release.json`)不可修改。

## 只更新商店信息

已发布的包想改名称、介绍、分类、截图或图标,不必发新版本:

- 网页:「我的软件包」里点「编辑商店信息」,表单带入线上内容,改完提交
(`POST /api/listing` → `repository_dispatch: web-listing-update`)。
- 命令行:`czdev publish --meta-only`,读取 `app-builder.json` 的 `store` 段。

两条通道都只改 `pool/main/<包名>/` 下的 `meta.json`、截图和图标,不动发布清单;
归属与元数据规则照常检查,通过后和版本更新一样自动合并。

## staging Release 清理

`staging` 是缓冲区:网页上传的 `.deb` 与商店图片、`czdev` PR 镜像的 `.deb`
都先放在这里。`prune-staging.yml` 每天(以及每次索引构建之后)清理:
已提升进 `apt-pool` 的 `.deb`、超过 14 天且没有任何 open PR 引用的 `.deb`、
超过 2 天的商店图片。open PR 还在用的 `.deb` 一律保留。可手动运行并勾选
`dry_run` 先看会删什么。

## 违规下架(管理员)

开发者自己的「下架」只移除某一个版本的清单。因违规需要整体下架时,由
admin/maintain 权限的维护者在 Actions 里运行 **Take Down Package**
(`takedown.yml`),填写包名与原因:

1. 删除 `pool/main/<包名>/` 整个目录(所有版本、商店信息、截图),自动合并并
重建索引,APT 与商店同时下线;
2. 合并后删除 `apt-pool` 与 `staging` 里该包的二进制,旧下载链接一并失效;
3. 默认把包名记入 `policy/takedowns.json`(原因、时间、执行人、原归属者),
两条提交通道都会拒绝这个包名,同一应用不能换个上传再回来;**不封禁账号**,
该开发者的其他应用照常;
4. 开 issue @原归属者说明原因;申诉在该 issue 里处理,解除保留就是删掉
`takedowns.json` 里对应的条目。

已经装到设备上的应用不受影响(设备端没有远程停用机制)。

## 初步安全报告(浏览器 + CI 双层)

- setuid/setgid、全局可写、设备文件、路径穿越
- 安装路径策略:对齐 Debian 的标准位置(`usr/bin/`、`usr/share/`、`usr/lib/`、
`opt/`、`etc/`、`var/lib/`、`usr/share/APPLaunch/`、`usr/share/doc/`、
`(usr/)lib/systemd/system/`),但共享目录里的文件名/目录名必须与包名相关
`(usr/)lib/systemd/system/`),但共享目录里的文件名/目录名必须以包名开头
(比对时忽略 `-` `_` `.` 的差异),因此装不进 `usr/bin/ls` 或 `sshd.service`;
`etc/default/`、`etc/logrotate.d/`、`usr/share/man/`、`usr/share/lintian/overrides/`、
`usr/share/bash-completion/completions/`、`usr/lib/tmpfiles.d/`、`usr/lib/udev/rules.d/`
这些 Debian 共享目录改为比对文件名(如 `70-<包名>.rules`、`<包名>.1.gz`),
udev 规则另外不得含 `RUN`、`PROGRAM`、`IMPORT{program}`;
`usr/local/` 与 `/bin` `/sbin` `/lib` 等 usr-merge 别名路径按 Debian Policy
9.1.2 / 10.1 一律拒绝。被拒时网页与失败 issue 都会给出规则说明和一段可直接
交给 AI 的提示词(前端 `site/debparse.js` 与 CI 的实现由测试保证逐字一致)。
Expand Down Expand Up @@ -99,8 +141,8 @@ Cloudflare Workers(不使用 Cloudflare 的 git 集成 / 自动拉取部署)
也可以本地 `wrangler secret put` 一次性设置)
3. push `main`(或手动 workflow_dispatch)→ 自动部署。
4. `packages-workflows/` 下的文件复制到 `CardputerZero/packages`:`*.yml`
进 `.github/workflows/`,`install_path_policy.py` 进 `.github/scripts/`
(一次性)。想恢复人工审核,网页通道把 `AUTO_MERGE` 改为 `"false"`,
进 `.github/workflows/`,`*.py` 与 `*.sh` 进 `.github/scripts/`
(每个文件第一行注明了目标路径)。想恢复人工审核,网页通道把 `AUTO_MERGE` 改为 `"false"`,
命令行通道删掉 `validate-pr-comment.yml` 里的 Merge 步骤;PR 仍会自动
创建,由管理员合并。
5. 仓库 Settings → Actions 的 fork PR 审批策略需放宽到
Expand Down
90 changes: 90 additions & 0 deletions packages-workflows/deb_summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
# Copy to CardputerZero/packages: .github/scripts/deb_summary.py
#
# The package facts a reviewer wants at a glance, as a markdown table, shared by
# both submission channels: process-web-submission.yml puts it in the publish
# PR body, validate-pr.yml in the result comment on a `czdev publish` PR. The
# portal's pre-check shows the same facts before upload.
#
# Usage: deb_summary.py <deb> [<published-version>]
#
# Prints the table on stdout. Never fails the caller: an unreadable field is
# shown as "?".

import io
import os
import subprocess
import sys
import tarfile

EM_AARCH64 = 183


def field(deb, name):
try:
return subprocess.run(["dpkg-deb", "-f", deb, name],
capture_output=True, text=True, check=True).stdout.strip()
except Exception:
return ""


def mib(n):
return f"{n / 1048576:.1f} MB"


def contents(deb):
"""(file count, ELF count, set of ELF machine names) from the data tarball."""
files = elf = 0
machines = set()
tb = subprocess.run(["dpkg-deb", "--fsys-tarfile", deb], capture_output=True, check=True).stdout
with tarfile.open(fileobj=io.BytesIO(tb)) as tf:
for m in tf.getmembers():
if not m.isfile():
continue
files += 1
head = tf.extractfile(m).read(20)
if len(head) >= 20 and head[:4] == b"\x7fELF":
elf += 1
machine = int.from_bytes(head[18:20], "little")
machines.add("arm64" if machine == EM_AARCH64 else f"e_machine={machine}")
return files, elf, machines


def cell(s):
# Field values come from the uploaded .deb: keep them from breaking the table.
return str(s).replace("|", "\\|").replace("\n", " ").strip() or "?"


def main(argv):
deb = argv[1]
published = argv[2] if len(argv) > 2 else ""
installed_kb = field(deb, "Installed-Size")
rows = [
("Package", f"`{field(deb, 'Package')}`"),
("Version", field(deb, "Version")),
("Previous version / 上一版", published or "— (new package / 新应用)"),
("Architecture", field(deb, "Architecture")),
("Maintainer", field(deb, "Maintainer")),
("Download size / 安装包大小", mib(os.path.getsize(deb))),
("Installed size / 安装后大小", mib(int(installed_kb) * 1024) if installed_kb.isdigit() else "?"),
]
try:
files, elf, machines = contents(deb)
rows.append(("Files / 文件数", files))
rows.append(("ELF binaries / ELF 二进制",
f"{elf} ({', '.join(sorted(machines))})" if elf else "0"))
except Exception:
rows.append(("Files / 文件数", "?"))
depends = field(deb, "Depends")
if depends:
rows.append(("Depends", depends))

print("| Field | Value |")
print("|---|---|")
for k, v in rows:
print(f"| {k} | {cell(v)} |")
return 0


if __name__ == "__main__":
sys.exit(main(sys.argv))
59 changes: 52 additions & 7 deletions packages-workflows/install_path_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,26 @@
ALIASED_ROOTS = ("bin/", "sbin/", "lib/", "lib32/", "lib64/", "libx32/")
SYSTEMD_DIRS = ("lib/systemd/system/", "usr/lib/systemd/system/")
UNIT_SUFFIX = re.compile(r"\.(service|socket|timer|target|path|mount)$")
# Shared Debian directories whose *files* carry the package name, rather than a
# subdirectory: /etc/default/<pkg>, NN-<pkg>.rules, <pkg>.1.gz and so on. Each
# entry is (directory pattern, what to strip from the file name before matching).
FILE_NAMED_DIRS = (
(re.compile(r"etc/default/"), None),
(re.compile(r"etc/logrotate\.d/"), None),
(re.compile(r"usr/share/lintian/overrides/"), None),
(re.compile(r"usr/share/bash-completion/completions/"), None),
(re.compile(r"usr/lib/tmpfiles\.d/"), re.compile(r"\.conf$")),
(re.compile(r"usr/lib/udev/rules\.d/"), re.compile(r"^\d+-|\.rules$")),
(re.compile(r"usr/share/man/(?:[^/]+/)?man[1-9][^/]*/"), re.compile(r"\.[1-9][^.]*(?:\.gz)?$")),
)
UDEV_RULES_DIR = "usr/lib/udev/rules.d/"
# udev runs these keys' commands as root, which apps must not do.
UDEV_EXEC_KEY = re.compile(r"(?:^|[\s,])(?:RUN|PROGRAM|IMPORT\{program\})(?!\w)")
UDEV_EXEC_REASON = ("udev rules must not use RUN, PROGRAM or IMPORT{program} (they run commands as root) / "
"udev 规则不能使用 RUN、PROGRAM、IMPORT{program}(这些会以 root 执行命令)")
ALLOWED_SUMMARY = ("允许 usr/bin/<与包名相关的可执行文件>、"
"usr/share|usr/lib|opt|var/lib 下与包名相关的子目录、etc/ 下与包名相关的配置、"
"etc/default、man、lintian、bash-completion、tmpfiles.d、udev、logrotate.d 下以包名命名的文件、"
"usr/share/APPLaunch/、usr/share/doc/、systemd 服务目录")


Expand All @@ -37,12 +55,23 @@ def normalize_name(s):


def named_after_pkg(name, pkg):
"""Is `name` recognizably the package's own? Separators are ignored and
either may prefix the other, so package fontpreview accepts a font_preview
directory and myapp accepts myapp-cli, but neither accepts ls."""
"""Is `name` recognizably the package's own? Separators are ignored and the
name must start with the package name, so package fontpreview accepts a
font_preview directory and a fontpreview-cli binary but not ls. Only this
direction counts: package sudoers-tool must not claim sudoers.d."""
a = normalize_name(UNIT_SUFFIX.sub("", name))
b = normalize_name(pkg or "")
return bool(a and b) and (a.startswith(b) or b.startswith(a))
return bool(a and b) and a.startswith(b)


def udev_rule_runs_programs(text):
"""Does this udev rule make udev execute something? RUN, PROGRAM and
IMPORT{program} all run as root, outside any service's User=."""
for raw in text.splitlines():
line = raw.strip()
if line and not line.startswith("#") and UDEV_EXEC_KEY.search(line):
return True
return False


def install_path_issue(path, pkg):
Expand Down Expand Up @@ -70,6 +99,15 @@ def install_path_issue(path, pkg):
return "Debian Policy 10.1 forbids /bin /sbin /lib etc. (usr-merge alias paths); use the /usr equivalent / Debian Policy 10.1 禁止软件包写入 /bin /sbin /lib 等 usr-merge 别名路径,请改用 /usr 下的对应位置"
if path.startswith("usr/share/APPLaunch/") or path.startswith("usr/share/doc/"):
return None
for pattern, strip in FILE_NAMED_DIRS:
m = pattern.match(path)
if m:
d, name = m.group(0), path[m.end():]
if "/" in name:
return f"no subdirectories under {d} / {d} 下不应再有子目录"
if named_after_pkg(strip.sub("", name) if strip else name, pkg):
return None
return f"the file name under {d} must relate to the package name {pkg} / {d} 下的文件名必须与包名 {pkg} 相关"
# /etc/<pkg>.conf is as much a Debian convention as /etc/<pkg>/ (compare
# rsyslog.conf, logrotate.conf), so a lone conffile is fine here.
if path.startswith("etc/"):
Expand Down Expand Up @@ -106,7 +144,7 @@ def install_path_issue(path, pkg):
请帮我修改打包脚本(CMakeLists.txt / debian/rules / install 脚本 / Makefile,看项目里用的是哪种),
把文件装到合规位置,并同步更新 .desktop 里的 Exec 和 Icon、以及代码里读取资源文件的路径。

商店的规则(包名记作 PKG,比对时忽略 - _ . 的差异,所以 PKG 是 fontpreview 时目录叫 font_preview 也算合规):
商店的规则(包名记作 PKG。“与包名相关”指名字以 PKG 开头,比对时忽略 - _ . 的差异,所以 PKG 是 fontpreview 时目录叫 font_preview、fontpreview-data 都算合规,叫 preview 不算):

允许安装到:
- usr/bin/<名字>:可执行文件,名字必须与包名相关(这是 Debian Policy 10.1 的标准位置)
Expand All @@ -115,6 +153,9 @@ def install_path_issue(path, pkg):
- opt/<与包名相关的目录>/**:整体打包自带的目录
- etc/<与包名相关的目录>/** 或 etc/<与包名相关>.conf:配置文件(Debian conffile 惯例)
- var/lib/<与包名相关的目录>/**:运行时数据
- etc/default/<PKG>、etc/logrotate.d/<PKG>、usr/share/lintian/overrides/<PKG>、usr/share/bash-completion/completions/<PKG>、
usr/lib/tmpfiles.d/<PKG>.conf、usr/share/man/manN/<PKG>.N.gz:Debian 标准的共享目录,目录名照旧,文件名以包名开头
- usr/lib/udev/rules.d/NN-<PKG>.rules:udev 规则,不能使用 RUN、PROGRAM 和 program 类型的 IMPORT(这些会以 root 执行命令)
- usr/share/APPLaunch/**:CardputerZero 启动器资源,且必须有 usr/share/APPLaunch/applications/<名字>.desktop
- usr/share/doc/**:文档
- lib/systemd/system/<与包名相关>.service 或 usr/lib/systemd/system/<同样>:服务名必须与包名相关,
Expand All @@ -139,14 +180,14 @@ def install_path_issue(path, pkg):
<summary>📁 Install path rules + a ready-to-paste AI prompt / 安装路径规则 + 可直接交给 AI 的提示词(点开)</summary>

We allow Debian's standard locations and only require that **anything landing
in a shared directory carries your package name**, so an app cannot quietly
in a shared directory starts with your package name**, so an app cannot quietly
shadow system commands or other packages' files. `-`, `_` and `.` are ignored
in the comparison, so package `fontpreview` may use a `font_preview` directory.
Copy the block below into an AI assistant (Cursor / Claude / ChatGPT); it
carries the full policy plus your rejected paths and can fix the packaging
scripts directly.

我们放行 Debian 的标准位置,只要求**落在共享目录里的文件名/目录名带上你的包名**,
我们放行 Debian 的标准位置,只要求**落在共享目录里的文件名/目录名以你的包名开头**,
这样一个应用不会悄悄覆盖系统命令或别人的文件。比对时忽略 `-`、`_`、`.` 的差异,
所以包名 `fontpreview` 用 `font_preview` 目录是可以的。
把下面整段复制给 AI(Cursor / Claude / ChatGPT),它带着完整规则和你这次被拒的路径,
Expand Down Expand Up @@ -176,6 +217,10 @@ def rejected_paths(deb, pkg):
if not path:
continue
why = install_path_issue(path, pkg)
if not why and path.startswith(UDEV_RULES_DIR) and member.isfile():
text = tf.extractfile(member).read().decode("utf-8", "replace")
if udev_rule_runs_programs(text):
why = UDEV_EXEC_REASON
if why:
# Backticks would break out of the code fence in the help text,
# and control characters would garble it.
Expand Down
38 changes: 38 additions & 0 deletions packages-workflows/package_owner.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Copy to CardputerZero/packages: .github/scripts/package_owner.sh
#
# Who owns a published package, by the manifests in the current checkout:
# package names are first-come-first-served, keyed on the uploader's GitHub
# login recorded as `uploaded_by` in the release manifest. Legacy manifests
# have no `uploaded_by`, so the login is derived from the published binary's
# Maintainer noreply address.
#
# Usage: package_owner.sh <package>
# Prints "<latest published version>|<owner login, lowercase>"; either part is
# empty when unknown (an unpublished package prints "|").

set -uo pipefail
PKG=$1

EXISTING="" ; LATEST_MANIFEST=""
for m in pool/main/"$PKG"/*.deb.release.json; do
[ -f "$m" ] || continue
v=$(jq -r '.version // empty' "$m")
if [ -n "$v" ] && { [ -z "$EXISTING" ] || dpkg --compare-versions "$v" gt "$EXISTING"; }; then
EXISTING="$v"; LATEST_MANIFEST="$m"
fi
done

OWNER_LOGIN=""
if [ -n "$LATEST_MANIFEST" ]; then
OWNER_LOGIN=$(jq -r '.uploaded_by // empty' "$LATEST_MANIFEST" | tr '[:upper:]' '[:lower:]')
if [ -z "$OWNER_LOGIN" ]; then
u=$(jq -r '.url // empty' "$LATEST_MANIFEST")
if [ -n "$u" ] && curl -fsL --max-time 300 -o /tmp/existing.deb "$u" 2>/dev/null; then
OM=$(dpkg-deb -f /tmp/existing.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true)
OWNER_LOGIN=$(printf '%s' "$OM" | sed -nE 's/^([0-9]+\+)?([^@]+)@users\.noreply\.github\.com$/\2/p')
fi
fi
fi

printf '%s|%s\n' "$EXISTING" "$OWNER_LOGIN"
Loading
Loading