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
58 changes: 33 additions & 25 deletions packages-workflows/process-web-submission.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
#
# Handles `repository_dispatch` (type: web-submission) sent by the developer
# portal Worker. Authoritative validation with real dpkg-deb: integrity,
# control fields, .desktop, structure & safety scan, maintainer-email
# ownership (first submitter owns the package name; admins bypass), and
# version monotonicity. On success it opens a publish PR (and can auto-merge
# it when AUTO_MERGE is true); on failure it opens an issue mentioning the
# submitter.
# control fields, .desktop, structure & safety scan, first-come-first-served
# ownership (the package name belongs to the GitHub login that first uploaded
# it, recorded as `uploaded_by`; admins bypass), and version monotonicity. On
# success it opens a publish PR (and can auto-merge it when AUTO_MERGE is true);
# on failure it opens an issue mentioning the submitter.

name: Process Web Submission

Expand Down Expand Up @@ -87,33 +87,35 @@ jobs:
fi
done

# --- ownership: maintainer email must be one of the submitter's
# verified emails; existing packages only updatable by owner/admin ---
EMAIL=$(echo "$MAINT" | grep -oP '<\K[^>]+' || echo "$MAINT")
EMAIL=$(echo "$EMAIL" | tr '[:upper:]' '[:lower:]')
MATCH=$(echo "$EMAILS_JSON" | jq --arg e "$EMAIL" 'map(ascii_downcase) | contains([$e])')
if [ "$MATCH" != "true" ] && [ "$IS_ADMIN" != "true" ]; then
ERR="${ERR}- ❌ Maintainer email \`$EMAIL\` is not one of @$SUBMITTER's verified GitHub emails\n"
fi

EXISTING="" ; OWNER_EMAIL=""
# --- ownership: package names are first-come-first-served, keyed on
# the uploader's GitHub login (recorded as `uploaded_by` in the
# release manifest). Existing packages are only updatable by the
# recorded owner or an admin. No email-vs-account matching. ---
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"
u=$(jq -r '.url // empty' "$m")
if [ -n "$u" ] && curl -fsL --max-time 300 -o /tmp/existing.deb "$u" 2>/dev/null; then
OWNER_EMAIL=$(dpkg-deb -f /tmp/existing.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true)
fi
EXISTING="$v"; LATEST_MANIFEST="$m"
fi
done
if [ -n "$OWNER_EMAIL" ] && [ "$IS_ADMIN" != "true" ]; then
OWNED=$(echo "$EMAILS_JSON" | jq --arg e "$OWNER_EMAIL" 'map(ascii_downcase) | contains([$e])')
if [ "$OWNED" != "true" ]; then
ERR="${ERR}- ❌ package '$PKG' is owned by \`$OWNER_EMAIL\`; only the owner or an admin can update it\n"
OWNER_LOGIN=""
if [ -n "$LATEST_MANIFEST" ]; then
OWNER_LOGIN=$(jq -r '.uploaded_by // empty' "$LATEST_MANIFEST" | tr '[:upper:]' '[:lower:]')
# Legacy manifests have no `uploaded_by`: derive the owner login
# from the published binary's Maintainer noreply address.
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
SUBMITTER_LC=$(printf '%s' "$SUBMITTER" | tr '[:upper:]' '[:lower:]')
if [ -n "$OWNER_LOGIN" ] && [ "$IS_ADMIN" != "true" ] && [ "$OWNER_LOGIN" != "$SUBMITTER_LC" ]; then
ERR="${ERR}- ❌ package '$PKG' is owned by @$OWNER_LOGIN; only the original uploader or an admin can update it\n"
fi
if [ -n "$EXISTING" ] && dpkg --compare-versions "$VER" le "$EXISTING"; then
ERR="${ERR}- ❌ version $VER is not newer than published $EXISTING\n"
fi
Expand Down Expand Up @@ -222,16 +224,22 @@ jobs:
WANT_SHA: ${{ github.event.client_payload.sha256 }}
SIZE: ${{ github.event.client_payload.size }}
SUBMITTER: ${{ github.event.client_payload.login }}
EMAILS_JSON: ${{ toJSON(github.event.client_payload.emails) }}
PKG: ${{ steps.validate.outputs.pkg }}
VER: ${{ steps.validate.outputs.ver }}
ARCH: ${{ steps.validate.outputs.arch }}
run: |
set -euo pipefail
NAME="${PKG}_${VER}_${ARCH}.deb"
ASSET_NAME="${NAME//\~/.}"
# Ownership record: first uploader of a package name owns it. The login
# is the authoritative owner key; the email is kept for contact/audit.
UPLOADER_EMAIL=$(echo "$EMAILS_JSON" | jq -r '.[0] // empty')
[ -n "$UPLOADER_EMAIL" ] || UPLOADER_EMAIL="${SUBMITTER}@users.noreply.github.com"
jq -n --arg f "$ASSET_NAME" --arg u "$DEB_URL" --arg s "$WANT_SHA" \
--arg p "$PKG" --arg v "$VER" --arg a "$ARCH" --argjson z "$SIZE" \
'{filename:$f,url:$u,sha256:$s,size:$z,package:$p,version:$v,architecture:$a}' \
--arg by "$SUBMITTER" --arg em "$UPLOADER_EMAIL" \
'{filename:$f,url:$u,sha256:$s,size:$z,package:$p,version:$v,architecture:$a,uploaded_by:$by,uploader_email:$em}' \
> "pool/main/$PKG/$ASSET_NAME.release.json"
BRANCH="publish/${PKG}-${VER}-web-${{ github.run_id }}"
git config user.name "cardputerzero-bot"
Expand Down
33 changes: 20 additions & 13 deletions packages-workflows/process-web-unpublish.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# Copy to CardputerZero/packages: .github/workflows/process-web-unpublish.yml
#
# Handles `repository_dispatch` (type: web-unpublish) from the developer
# portal. Re-verifies ownership (the published manifest's deb Maintainer must
# be one of the requester's verified emails, unless admin), removes the
# manifest and opens a removal PR (auto-merged when AUTO_MERGE is true).
# portal. Re-verifies ownership by GitHub login (the manifest's recorded
# `uploaded_by`, or the login from the binary's Maintainer noreply address for
# legacy manifests; admins bypass), removes the manifest and opens a removal PR
# (auto-merged when AUTO_MERGE is true).
# Removing the manifest drops the package from the APT index on the next
# update-index build.

Expand Down Expand Up @@ -49,17 +50,23 @@ jobs:
if [ ! -f "$MANIFEST" ]; then
ERR="- ❌ manifest not found: \`$MANIFEST\`\n"
else
URL=$(jq -r '.url // empty' "$MANIFEST")
OWNER_EMAIL=""
if [ -n "$URL" ] && curl -fsL --max-time 300 -o /tmp/pkg.deb "$URL" 2>/dev/null; then
OWNER_EMAIL=$(dpkg-deb -f /tmp/pkg.deb Maintainer 2>/dev/null | grep -oP '<\K[^>]+' | tr '[:upper:]' '[:lower:]' || true)
# Ownership is first-come-first-served by GitHub login. Prefer the
# recorded `uploaded_by`; fall back to the login encoded in the
# published binary's Maintainer noreply address for legacy manifests.
OWNER_LOGIN=$(jq -r '.uploaded_by // empty' "$MANIFEST" | tr '[:upper:]' '[:lower:]')
if [ -z "$OWNER_LOGIN" ]; then
URL=$(jq -r '.url // empty' "$MANIFEST")
if [ -n "$URL" ] && curl -fsL --max-time 300 -o /tmp/pkg.deb "$URL" 2>/dev/null; then
OM=$(dpkg-deb -f /tmp/pkg.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
SUBMITTER_LC=$(printf '%s' "$SUBMITTER" | tr '[:upper:]' '[:lower:]')
if [ "$IS_ADMIN" != "true" ]; then
if [ -z "$OWNER_EMAIL" ]; then
ERR="- ❌ could not verify ownership (binary unreachable); ask an admin\n"
else
OWNED=$(echo "$EMAILS_JSON" | jq --arg e "$OWNER_EMAIL" 'map(ascii_downcase) | contains([$e])')
[ "$OWNED" = "true" ] || ERR="- ❌ package is owned by \`$OWNER_EMAIL\`, not @$SUBMITTER\n"
if [ -z "$OWNER_LOGIN" ]; then
ERR="- ❌ could not verify ownership of \`$PKG\` (no recorded uploader); ask an admin\n"
elif [ "$OWNER_LOGIN" != "$SUBMITTER_LC" ]; then
ERR="- ❌ package '$PKG' is owned by @$OWNER_LOGIN, not @$SUBMITTER\n"
fi
fi
fi
Expand All @@ -73,7 +80,7 @@ jobs:
git push origin "$BRANCH"
PR_URL=$(gh pr create --base main --head "$BRANCH" \
--title "unpublish: $PKG $VER (portal request by @$SUBMITTER)" \
--body "Removal requested by @$SUBMITTER via the developer portal. Ownership verified against the published binary's Maintainer email.")
--body "Removal requested by @$SUBMITTER via the developer portal. Ownership verified against the recorded uploader (\`uploaded_by\`).")
if [ "$AUTO_MERGE" = "true" ]; then
gh pr merge "$PR_URL" --squash --delete-branch || echo "::warning::auto-merge failed; awaiting manual review"
fi
Expand Down
12 changes: 12 additions & 0 deletions site/debparse.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ export function extractEmail(maintainer) {
return m ? m[1].trim() : (maintainer || "").trim();
}

/**
* Derive a GitHub login from a Maintainer string whose email is a GitHub
* noreply address, else "". Handles both `login@users.noreply.github.com` and
* the newer `12345+login@users.noreply.github.com` form. Used for ownership
* attribution when no explicit `uploaded_by` record exists yet.
*/
export function loginFromNoreply(maintainer) {
const email = extractEmail(maintainer).toLowerCase();
const m = /^(?:\d+\+)?([^@]+)@users\.noreply\.github\.com$/.exec(email);
return m ? m[1] : "";
}

/* --------------------------- dpkg version compare ------------------------- */

function chOrder(c) {
Expand Down
11 changes: 10 additions & 1 deletion test/debparse.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { gunzipSync } from "node:zlib";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { parseDeb, compareDebVersions, extractEmail } from "../site/debparse.js";
import { parseDeb, compareDebVersions, extractEmail, loginFromNoreply } from "../site/debparse.js";
import { decompress as fzstdDecompress } from "../site/vendor/fzstd.js";
import { decompress as bz2Decompress } from "../site/vendor/bz2.js";
import { LZMA } from "../site/vendor/lzma-d.js";
Expand Down Expand Up @@ -163,3 +163,12 @@ test("dpkg version semantics", () => {
assert.equal(compareDebVersions("2:0.1", "1:9.9"), 1);
assert.equal(extractEmail("A B <a@b.c>"), "a@b.c");
});

test("loginFromNoreply extracts owner login for ownership attribution", () => {
assert.equal(loginFromNoreply("Dev <dev@users.noreply.github.com>"), "dev");
assert.equal(loginFromNoreply("Some One <12345+someone@users.noreply.github.com>"), "someone");
assert.equal(loginFromNoreply("EGG <EGG@users.noreply.github.com>"), "egg");
// Non-noreply (real) emails are not attributable to a login.
assert.equal(loginFromNoreply("Real <real@example.com>"), "");
assert.equal(loginFromNoreply(""), "");
});
25 changes: 12 additions & 13 deletions worker/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* Vars: see wrangler.toml.
*/

import { compareDebVersions, extractEmail } from "../../site/debparse.js";
import { compareDebVersions, loginFromNoreply } from "../../site/debparse.js";

const SESSION_COOKIE = "cz_session";
const STATE_COOKIE = "cz_oauth_state";
Expand Down Expand Up @@ -192,15 +192,19 @@ async function apiSubmit(request, env) {
}

// Pre-flight against the published index: name squatting + version bump.
// (Authoritative re-check happens in the packages-repo Action.)
// Ownership is first-come-first-served by GitHub login. The Worker can only
// read the APT index (Maintainer), so this is a best-effort pre-check that
// blocks only when the name is positively attributable to a *different*
// login; the packages-repo Action re-checks authoritatively against the
// recorded `uploaded_by`.
const index = await fetchIndex(env);
const entries = index.get(pkg) || [];
if (entries.length) {
const owner = extractEmail(entries[0].maintainer).toLowerCase();
if (!s.a && !s.e.includes(owner)) {
const ownerLogin = loginFromNoreply(entries[0].maintainer);
if (!s.a && ownerLogin && ownerLogin !== s.l.toLowerCase()) {
return json(403, {
error: "not_owner",
detail: `包名 "${pkg}" 已被 ${maskEmail(owner)} 占用,只有该邮箱对应的 GitHub 账号或管理员可以更新/下架`,
detail: `包名 "${pkg}" 已被 @${ownerLogin} 占用,只有其本人或管理员可以更新/下架`,
});
}
const latest = entries.map((e) => e.version).sort(compareDebVersions).pop();
Expand Down Expand Up @@ -271,9 +275,9 @@ async function apiUnpublish(request, env) {
const entry = entries.find((e) => e.version === version);
if (!entry) return json(404, { error: "not_published", detail: `${pkg} ${version} 不在线上索引中` });

const owner = extractEmail(entry.maintainer).toLowerCase();
if (!s.a && !s.e.includes(owner)) {
return json(403, { error: "not_owner", detail: "只有包的 Maintainer 邮箱对应的账号或管理员可以下架" });
const ownerLogin = loginFromNoreply(entry.maintainer);
if (!s.a && ownerLogin && ownerLogin !== s.l.toLowerCase()) {
return json(403, { error: "not_owner", detail: `包 "${pkg}" 由 @${ownerLogin} 上传,只有其本人或管理员可以下架` });
}

const dispatch = await gh(env, `/repos/${env.TARGET_OWNER}/${env.TARGET_REPO}/dispatches`, {
Expand Down Expand Up @@ -446,8 +450,3 @@ function unb64url(str) {
return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
}

function maskEmail(email) {
const [user, domain] = email.split("@");
if (!domain) return "***";
return `${user.slice(0, 2)}***@${domain}`;
}
Loading