diff --git a/.github/workflows/linux-private-build.yml b/.github/workflows/linux-private-build.yml
new file mode 100644
index 00000000..e73d6a76
--- /dev/null
+++ b/.github/workflows/linux-private-build.yml
@@ -0,0 +1,511 @@
+name: Linux Private Build
+
+# Linux 的发布构建。与 macOS 的 macos-private-build.yml 对齐,但签名模型不同:
+# Linux 没有代码签名,原生组件的身份 = 内容哈希(linuxIntegrityMode: content-hash-pin)。
+#
+# 整个链路只有两个外部输入:
+# 1. 私藏仓 `2977094657/WCDB` 的 main revision(发版当下由
+# tools/rebuild_wcdb_release.py 现产一份 source-public 原生核心);
+# 2. 同一个 revision 里的 private/wce_integrity 源码(用于编译导出完整性模块)。
+#
+# 也就是说,与 Windows / macOS 同一条路线:**不需要任何仓库变量或额外的读取
+# secret**,只复用发版已有的 `WCE_NATIVE_CORE_PRODUCER_TOKEN`。45 天有效期由
+# 「每次发版重建」自然续上,不再有手工 pin 会过期。
+#
+# 产物形态刻意不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg,
+# Linux 走「用户级、免 root 的 tar.gz + install.sh」。electron-builder 只出 dir 目标。
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ description: Package version; omitted callers derive it from a v* tag
+ required: false
+ type: string
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Package version (for example 2.5.2)
+ required: true
+ type: string
+
+permissions:
+ actions: read
+ contents: read
+
+jobs:
+ build-linux-x64:
+ if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
+ runs-on: ubuntu-22.04
+ timeout-minutes: 150
+ env:
+ UV_MANAGED_PYTHON: "true"
+ # 原生核心的 producer 仓是常量;发版当下由 rebuild_wcdb_release.py 现产一份。
+ WCE_LINUX_NATIVE_REPOSITORY: "2977094657/WCDB"
+ WCE_NATIVE_CORE_REQUIRED: "1"
+ WCE_NATIVE_CORE_ALLOW_DEVELOPMENT_ARTIFACTS: "0"
+ CI: "true"
+ PACKAGE_VERSION_INPUT: ${{ inputs.version }}
+ steps:
+ - name: Checkout exact release revision
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ ref: ${{ github.sha }}
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Verify immutable source and release coordinates
+ shell: bash
+ env:
+ WORKFLOW_REF: ${{ github.ref }}
+ WORKFLOW_REVISION: ${{ github.sha }}
+ run: |
+ set -euo pipefail
+ [[ "$WORKFLOW_REVISION" =~ ^[0-9a-f]{40}$ ]]
+ test "$(git rev-parse HEAD)" = "$WORKFLOW_REVISION"
+ test -z "$(git status --porcelain=v1 --untracked-files=all)"
+
+ requested_version="${PACKAGE_VERSION_INPUT#v}"
+ case "$WORKFLOW_REF" in
+ refs/heads/main)
+ test "$(git rev-parse origin/main)" = "$WORKFLOW_REVISION"
+ package_version="$requested_version"
+ ;;
+ refs/tags/v*)
+ tag="${WORKFLOW_REF#refs/tags/}"
+ tag_version="${tag#v}"
+ test "$tag" = "$GITHUB_REF_NAME"
+ test "$(git rev-parse --verify "${WORKFLOW_REF}^{commit}")" = "$WORKFLOW_REVISION"
+ git rev-parse --verify origin/main
+ git merge-base --is-ancestor "$WORKFLOW_REF" origin/main
+ if [[ -n "$requested_version" ]]; then
+ test "$requested_version" = "$tag_version"
+ fi
+ package_version="$tag_version"
+ ;;
+ *)
+ echo "Linux packages may only be built from main or a v* release tag" >&2
+ exit 1
+ ;;
+ esac
+ [[ "$package_version" =~ ^[0-9]+(\.[0-9]+)+([.-][A-Za-z0-9.-]+)?$ ]]
+ printf 'PACKAGE_VERSION=%s\n' "$package_version" >> "$GITHUB_ENV"
+
+ # 原生核心与 integrity 源码都来自同一个 producer 仓,不需要任何仓库变量;
+ # 下面这一步会现产一份 source-public 核心并把五元组写进 GITHUB_ENV。
+ # 生产仓必须是 rebuild 脚本里写死的那个,不允许被变量悄悄换掉。
+ [[ "$WCE_LINUX_NATIVE_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]
+ script_repository="$(
+ python3 -c 'import re,sys;print(re.search(r"^REPOSITORY = \"([^\"]+)\"", open(sys.argv[1], encoding="utf-8").read(), re.M).group(1))' \
+ tools/rebuild_wcdb_release.py
+ )"
+ test "$script_repository" = "$WCE_LINUX_NATIVE_REPOSITORY"
+
+ - name: Setup Node.js
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: "20"
+ cache: npm
+ cache-dependency-path: |
+ frontend/package-lock.json
+ desktop/package-lock.json
+
+ - name: Setup Python
+ uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version-file: .python-version
+
+ - name: Install build dependencies
+ shell: bash
+ run: |
+ set -euo pipefail
+ python -m pip install uv
+ uv python install 3.11
+ npm ci --prefix frontend
+ npm ci --prefix desktop
+
+ - name: Rebuild the Linux native core for this release
+ timeout-minutes: 45
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_PRODUCER_TOKEN }}
+ run: |
+ set -euo pipefail
+ if [ -z "${GH_TOKEN:-}" ]; then
+ echo "WCE_NATIVE_CORE_PRODUCER_TOKEN is required to rebuild the WCDB native core." >&2
+ exit 1
+ fi
+ # 与 Windows / macOS 同一条路线:现产一份 source-public 原生核心,把五元组写进
+ # GITHUB_ENV(WCE_NATIVE_CORE_ARTIFACT_REPOSITORY / _RUN_ID / _SHA256 /
+ # _SOURCE_REVISION / _BUILD_ID / _CLIENT_SHA256 / _BROKER_SHA256 /
+ # _ARTIFACT_DIR),供后面的策略校验与打包步骤核对。这里不依赖任何仓库变量,
+ # 也不依赖 Actions artifact 存储(资产只走不可变 Release)。
+ python3 tools/rebuild_wcdb_release.py \
+ --component linux-native \
+ --output-root "$RUNNER_TEMP"
+ # 注意:脚本写进 GITHUB_ENV 的变量只对**后续 step** 可见,本 step 的 shell
+ # 读不到,所以这里只用常量拼路径;其余坐标交给后面的步骤用。
+ core_dir="$RUNNER_TEMP/wechatdb-native-linux-x64-source-public"
+ test -f "$core_dir/wechatdb_native_build.json"
+
+ - name: Validate the pinned native core against the production policy
+ shell: bash
+ env:
+ WCE_NATIVE_CORE_ARTIFACT_DIR: ${{ runner.temp }}/wechatdb-native-linux-x64-source-public
+ run: |
+ set -euo pipefail
+ node -e "require('./desktop/scripts/linux-native-core-packaging.cjs').resolveLinuxNativeCoreArtifacts({ platform: 'linux' })"
+ node -e "
+ const resolved = require('./desktop/scripts/linux-native-core-packaging.cjs')
+ .resolveLinuxNativeCoreArtifacts({ platform: 'linux' });
+ const manifest = resolved.manifest;
+ console.log(JSON.stringify({
+ buildId: manifest.buildId,
+ expiresAtUnix: manifest.buildExpiresAtUnix,
+ clientSha256: manifest.linuxClientSha256,
+ brokerSha256: manifest.linuxBrokerSha256,
+ hostVerification: manifest.linuxHostVerification,
+ sourceRuntime: manifest.sourceRuntime === true,
+ }, null, 2));
+ "
+ printf 'WCE_NATIVE_CORE_ARTIFACT_DIR=%s\n' "$WCE_NATIVE_CORE_ARTIFACT_DIR" >> "$GITHUB_ENV"
+
+ - name: Checkout the private integrity source at the producer revision
+ timeout-minutes: 15
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.WCE_NATIVE_CORE_PRODUCER_TOKEN }}
+ run: |
+ set -euo pipefail
+ if [ -z "${GH_TOKEN:-}" ]; then
+ echo "WCE_NATIVE_CORE_PRODUCER_TOKEN is required to fetch the private integrity source." >&2
+ exit 1
+ fi
+ # wce_integrity 把 Nuxt 的 CSS 编进导出物里,所以它必须在 UI 构建之后、
+ # 在同一个工作目录里编译(macOS 那份 prebuilt dylib 之所以要记
+ # uiSourceRevision,就是因为这个耦合)。这里改为按 revision 取源码,
+ # 用构建密钥(一次性 P-256 私钥)现编,语义与官方
+ # `-GenerateEphemeralSigningKey` 一致:该密钥只用于导出物自身封签,
+ # 权威封印是原生核心产出的 WES2 sidecar。
+ work="$RUNNER_TEMP/wce-integrity-source"
+ archive="$RUNNER_TEMP/wce-integrity-source.tar.gz"
+ rm -rf "$work"
+ mkdir -p "$work"
+ rm -f "$archive"
+ # integrity 源码与当次被钉的核心同源同 revision(都由重建步骤写进环境)。
+ gh api "repos/$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY/tarball/$WCE_NATIVE_CORE_SOURCE_REVISION" > "$archive"
+ test -s "$archive"
+ tar -xzf "$archive" -C "$work"
+ root="$(find "$work" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
+ test -n "$root"
+ source_dir="$root/private/wce_integrity"
+ test -f "$source_dir/Cargo.toml"
+ test -f "$source_dir/build.rs"
+ rm -rf native/wce_integrity
+ mkdir -p native
+ mv "$source_dir" native/wce_integrity
+ rm -rf "$work" "$archive"
+ test -f native/wce_integrity/Cargo.toml
+
+ - name: Install the Rust toolchain
+ uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
+ with:
+ toolchain: stable
+
+ - name: Run focused Python release tests
+ shell: bash
+ env:
+ PYTHONPATH: src
+ run: |
+ set -euo pipefail
+ # 只跑在 Linux 上成立的用例:Windows 专属的原生核心用例(
+ # test_wcdb_realtime_native_core_required / test_native_core_broker_lifecycle)
+ # 依赖 win32 的 PE 与 trust-mode 语义,在这里必然失败,不应作为门禁。
+ # test_linux_native_core_policy.py 钉住 Linux 的授权矩阵:冻结应用必须接受
+ # 发布工作流实际发的 source-public 产物(与 Windows 同一原则)。
+ uv run pytest -q \
+ tests/test_linux_db_key_flow.py \
+ tests/test_linux_db_key_frontend.py \
+ tests/test_linux_native_core_policy.py \
+ tests/test_native_core_device_credential.py
+
+ - name: Run focused desktop release tests
+ working-directory: desktop
+ shell: bash
+ run: |
+ set -euo pipefail
+ # 只跑与 Linux 打包契约直接相关的用例。windows-* 那几个在 Linux 上必然
+ # 失败(依赖 PE 与 Windows trust-mode 语义),不能当门禁。
+ # native-core-runtime 是桌面启动后端的门禁:Linux 的 schema v4 判定错了,
+ # 打出来的包一启动就会崩,所以它必须在这里跑。
+ node --test \
+ tests/native-core-runtime.test.cjs \
+ tests/native-core-packaging.test.cjs \
+ tests/native-core-before-pack.test.cjs \
+ tests/package-config.test.cjs
+
+ - name: Set desktop app version
+ working-directory: desktop
+ shell: bash
+ run: npm version "$PACKAGE_VERSION" --no-git-tag-version --allow-same-version
+
+ - name: Build the Linux package
+ working-directory: desktop
+ shell: bash
+ env:
+ CSC_IDENTITY_AUTO_DISCOVERY: "false"
+ run: |
+ set -euo pipefail
+ npm run dist:linux
+
+ - name: Verify the packaged Linux runtime
+ working-directory: desktop
+ shell: bash
+ env:
+ WCE_NATIVE_CORE_ARTIFACT_DIR: ${{ runner.temp }}/wechatdb-native-linux-x64-source-public
+ run: |
+ set -euo pipefail
+ node - <<'NODE'
+ const childProcess = require("node:child_process");
+ const fs = require("node:fs");
+ const path = require("node:path");
+ const {
+ inspectElf,
+ linuxContentPinErrors,
+ } = require("./scripts/linux-native-core-packaging.cjs");
+
+ const artifactDir = process.env.WCE_NATIVE_CORE_ARTIFACT_DIR;
+ const payload = path.resolve("dist", "linux-unpacked");
+ const backendRoot = path.join(payload, "resources", "backend");
+ const nativeDir = path.join(backendRoot, "native");
+
+ const requireFile = (filePath, { executable = false } = {}) => {
+ const stat = fs.statSync(filePath);
+ if (!stat.isFile() || stat.size <= 0) throw new Error(`not a file: ${filePath}`);
+ if (executable && (stat.mode & 0o111) === 0) throw new Error(`not executable: ${filePath}`);
+ return stat;
+ };
+ const digest = (filePath) =>
+ require("node:crypto").createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+
+ // 应用本体与后端可执行文件。
+ requireFile(path.join(payload, "wechat-data-analysis"), { executable: true });
+ requireFile(path.join(backendRoot, "wechat-backend"), { executable: true });
+
+ // 原生三件套必须与 pin 过的产物逐字节一致:打包过程不允许「顺手重编」。
+ for (const name of ["libwechatdb_client.so", "wechatdb_broker", "wechatdb_native_build.json"]) {
+ const packaged = path.join(nativeDir, name);
+ requireFile(packaged);
+ const expected = digest(path.join(artifactDir, name));
+ const actual = digest(packaged);
+ if (actual !== expected) {
+ throw new Error(`packaged ${name} differs from the reviewed native artifact`);
+ }
+ }
+
+ // manifest 声明的内容哈希必须描述打包后的这两个文件本身。
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(nativeDir, "wechatdb_native_build.json"), "utf8")
+ );
+ const pinErrors = linuxContentPinErrors({ directory: nativeDir, manifest });
+ if (pinErrors.length > 0) {
+ throw new Error(`packaged native core failed its own content pins: ${pinErrors.join("; ")}`);
+ }
+ if (manifest.sourceRuntime !== true || manifest.linuxHostVerification !== "same-user-direct-parent") {
+ throw new Error("packaged native core is not the source-public profile");
+ }
+ if (manifest.buildExpiresAtUnix * 1000 <= Date.now()) {
+ throw new Error("packaged native core build window has already expired");
+ }
+
+ // 桌面应用启动后端时要走的同一条策略判定,必须在**打包后的产物**上通过:
+ // 这里是「装完能用」的唯一自动化门禁(Release 发布前就拦下 schema 漂移)。
+ const { resolveNativeCoreRuntimePolicy, applyNativeCoreRuntimePolicy } =
+ require("./src/native-core-runtime.cjs");
+ const packagedPolicy = resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir,
+ platform: "linux",
+ });
+ if (packagedPolicy.mode !== "required" || packagedPolicy.artifactState !== "production") {
+ throw new Error(
+ `packaged native core did not resolve a required production runtime policy: ${JSON.stringify({ mode: packagedPolicy.mode, artifactState: packagedPolicy.artifactState })}`
+ );
+ }
+ const backendEnv = {};
+ applyNativeCoreRuntimePolicy(backendEnv, {
+ isPackaged: true,
+ nativeDir,
+ platform: "linux",
+ });
+ if (backendEnv.WECHAT_TOOL_NATIVE_CORE_MODE !== "required") {
+ throw new Error("packaged native core did not enforce the required native-core mode");
+ }
+
+ // ELF 身份:客户端/完整性模块是共享对象,broker 是 x86-64 可执行文件。
+ const clientElf = inspectElf(path.join(nativeDir, "libwechatdb_client.so"));
+ if (!clientElf.isSharedObject) throw new Error("packaged native client is not an ELF shared object");
+ const brokerElf = inspectElf(path.join(nativeDir, "wechatdb_broker"));
+ if (!brokerElf.isExecutable) throw new Error("packaged broker is not an ELF executable");
+ const integrity = path.join(nativeDir, "libwce_integrity.so");
+ requireFile(integrity);
+ if (!inspectElf(integrity).isSharedObject) {
+ throw new Error("packaged wce_integrity module is not an ELF shared object");
+ }
+
+ // 一键安装素材:归档里必须有应用本体与原生核心,install.sh 必须内嵌归档摘要。
+ const productName = JSON.parse(fs.readFileSync("package.json", "utf8")).build.productName;
+ const version = JSON.parse(fs.readFileSync("package.json", "utf8")).version;
+ const archiveName = `${productName}-${version}-linux-x86_64.tar.gz`;
+ const archivePath = path.join("dist", archiveName);
+ requireFile(archivePath);
+ const installerPath = path.join("dist", "install.sh");
+ requireFile(installerPath, { executable: true });
+ const installer = fs.readFileSync(installerPath, "utf8");
+ const archiveDigest = digest(archivePath);
+ if (!installer.includes(archiveDigest)) {
+ throw new Error("install.sh does not embed the payload archive digest");
+ }
+ const members = childProcess
+ .execFileSync("tar", ["-tzf", archivePath], { encoding: "utf8" })
+ .split("\n")
+ .map((name) => name.replace(/^\.\//, ""))
+ .filter(Boolean);
+ for (const name of [
+ "wechat-data-analysis",
+ "resources/backend/wechat-backend",
+ "resources/backend/native/libwechatdb_client.so",
+ "resources/backend/native/wechatdb_broker",
+ ]) {
+ if (!members.includes(name)) throw new Error(`payload archive is missing ${name}`);
+ }
+ console.log(`verified Linux payload ${archiveName} (${archiveDigest})`);
+ NODE
+
+ - name: Prepare Linux release checksums and provenance
+ working-directory: desktop
+ shell: bash
+ env:
+ WDA_REPOSITORY: ${{ github.repository }}
+ WDA_REVISION: ${{ github.sha }}
+ WDA_TAG: ${{ github.ref_name }}
+ WORKFLOW_RUN_ID: ${{ github.run_id }}
+ WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }}
+ run: |
+ set -euo pipefail
+ # 原生核心坐标来自上面重建步骤写进 GITHUB_ENV 的变量(不是仓库变量);
+ # 也不能写进 step 的 env: 里用表达式读,那种展开发生在解析期,看不到本
+ # 作业运行时才写入的值。
+ export NATIVE_REPOSITORY="$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY"
+ export NATIVE_RUN_ID="$WCE_NATIVE_CORE_ARTIFACT_RUN_ID"
+ export NATIVE_REVISION="$WCE_NATIVE_CORE_SOURCE_REVISION"
+ export NATIVE_BUILD_ID="$WCE_NATIVE_CORE_BUILD_ID"
+ export NATIVE_ASSET_SHA256="$WCE_NATIVE_CORE_ARTIFACT_SHA256"
+ # integrity 源码来自同一个 producer revision,所以溯源字段与原生核心同源。
+ export LINUX_INTEGRITY_SOURCE_REPOSITORY="$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY"
+ export LINUX_INTEGRITY_SOURCE_REVISION="$WCE_NATIVE_CORE_SOURCE_REVISION"
+ cd dist
+ # Windows 那份叫 SHA256SUMS.txt / release-provenance.json;Linux 用带后缀的
+ # 名字,避免两个作业的产物在 merge-multiple 下载时互相覆盖。
+ test -f SHA256SUMS.txt
+ mv SHA256SUMS.txt SHA256SUMS-linux.txt
+ sha256sum -c SHA256SUMS-linux.txt
+
+ node - <<'NODE'
+ const crypto = require("node:crypto");
+ const fs = require("node:fs");
+ const path = require("node:path");
+
+ const digest = (filePath) =>
+ crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+ const positiveInteger = (name) => {
+ const value = Number(process.env[name]);
+ if (!Number.isSafeInteger(value) || value <= 0) {
+ throw new Error(`${name} is not a positive integer`);
+ }
+ return value;
+ };
+
+ const nativeDir = path.resolve(
+ "linux-unpacked",
+ "resources",
+ "backend",
+ "native"
+ );
+ const manifest = JSON.parse(
+ fs.readFileSync(path.join(nativeDir, "wechatdb_native_build.json"), "utf8")
+ );
+
+ const assets = fs
+ .readdirSync(".")
+ .filter((name) => name.endsWith("-linux-x86_64.tar.gz") || name === "install.sh")
+ .sort();
+ if (assets.length !== 2) {
+ throw new Error(`expected exactly one payload archive and install.sh: ${assets.join(", ")}`);
+ }
+ const artifacts = assets.map((name) => ({
+ path: name,
+ sha256: digest(name),
+ size: fs.statSync(name).size,
+ }));
+
+ const provenance = {
+ schemaVersion: 1,
+ artifactName: "release-linux-x64",
+ source: {
+ repository: process.env.WDA_REPOSITORY,
+ revision: process.env.WDA_REVISION,
+ tag: process.env.WDA_TAG,
+ },
+ native: {
+ repository: process.env.NATIVE_REPOSITORY,
+ workflowRunId: positiveInteger("NATIVE_RUN_ID"),
+ sourceRevision: process.env.NATIVE_REVISION,
+ buildId: process.env.NATIVE_BUILD_ID,
+ artifactSha256: process.env.NATIVE_ASSET_SHA256,
+ distributionMode: manifest.distributionMode,
+ integrityMode: manifest.linuxIntegrityMode,
+ linuxClientSha256: manifest.linuxClientSha256,
+ linuxBrokerSha256: manifest.linuxBrokerSha256,
+ linuxPeerVerification: manifest.linuxPeerVerification,
+ linuxHostVerification: manifest.linuxHostVerification,
+ sourceRuntime: manifest.sourceRuntime === true,
+ offlineBootstrapFeatureBits: manifest.offlineBootstrapFeatureBits,
+ offlineExportSealFormat: manifest.offlineExportSealFormat,
+ securityNoticeId: manifest.securityNoticeId,
+ securityNoticeSha256: manifest.securityNoticeSha256,
+ securityCheckpointSetId: manifest.securityCheckpointSetId,
+ securityCheckpointCount: manifest.securityCheckpointCount,
+ securityCheckpointSetSha256: manifest.securityCheckpointSetSha256,
+ },
+ integrity: {
+ sourceRepository: process.env.LINUX_INTEGRITY_SOURCE_REPOSITORY,
+ sourceRevision: process.env.LINUX_INTEGRITY_SOURCE_REVISION,
+ binarySha256: digest(path.join(nativeDir, "libwce_integrity.so")),
+ signingKey: "ephemeral-build-key",
+ },
+ build: {
+ workflowRunId: positiveInteger("WORKFLOW_RUN_ID"),
+ workflowRunAttempt: positiveInteger("WORKFLOW_RUN_ATTEMPT"),
+ },
+ artifacts,
+ };
+ fs.writeFileSync(
+ "release-provenance-linux.json",
+ `${JSON.stringify(provenance, null, 2)}\n`
+ );
+ console.log(JSON.stringify(provenance, null, 2));
+ NODE
+
+ - name: Upload Linux release files
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: release-linux-x64
+ if-no-files-found: error
+ retention-days: 14
+ path: |
+ desktop/dist/*-linux-x86_64.tar.gz
+ desktop/dist/install.sh
+ desktop/dist/SHA256SUMS-linux.txt
+ desktop/dist/release-provenance-linux.json
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 8cc3bd5d..b1b10f11 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,4 @@
-name: Release (Windows and macOS ARM64)
+name: Release (Windows, macOS ARM64 and Linux x64)
on:
push:
@@ -711,10 +711,17 @@ jobs:
uses: ./.github/workflows/macos-private-build.yml
secrets: inherit
+ # Linux 与 Windows/macOS 同为必需平台:pin 没配齐就整个 release 失败(fail closed),
+ # 不允许「静默少发一个平台」。准备步骤见 linux-private-build.yml 顶部的注释。
+ build-linux-x64:
+ uses: ./.github/workflows/linux-private-build.yml
+ secrets: inherit
+
publish-release:
needs:
- build-windows
- build-macos-arm64
+ - build-linux-x64
runs-on: ubuntu-latest
steps:
- name: Checkout release history
@@ -842,84 +849,3 @@ jobs:
subprocess.run(["gh", "run", "watch", str(match["id"]), "--repo", repository, "--exit-status"], check=True)
PY
- # ========================== QQ 群通知 ==========================
- # 等 Release 发布完成后,发送 QQ 群通知。
- # 消息内容取最后一次 commit 正文(用户约定在此写本次更新说明)。
- # Windows exe 通过分块上传直传 QQ(GitHub CDN 在大陆不可访问)。
- qq-notify:
- needs: [publish-release]
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- with:
- fetch-depth: 1
- persist-credentials: false
-
- - name: Prepare release info & download artifacts
- id: prep
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- VERSION="${{ github.ref_name }}"
- VER="${VERSION#v}"
- EXE="WeChatDataAnalysis-${VER}-Setup.exe"
- DMG="WeChatDataAnalysis-${VER}-mac-arm64.dmg"
- EXE_7Z="${EXE}.7z"
- DMG_7Z="${DMG}.7z"
- BASE_URL="https://github.com/${{ github.repository }}/releases/download/${VERSION}"
- EXE_URL="${BASE_URL}/${EXE}"
- MAC_ARM64_URL="${BASE_URL}/${DMG}"
-
- gh release download "${VERSION}" --pattern "${EXE}" --pattern "${DMG}" --dir /tmp/release
-
- 7z a /tmp/release/${EXE_7Z} /tmp/release/${EXE}
- 7z a /tmp/release/${DMG_7Z} /tmp/release/${DMG}
-
- BODY=$(git log -1 --pretty=format:%b)
- [ -z "$BODY" ] && BODY=$(git log -1 --pretty=format:%s)
-
- echo "version=${VERSION}" >> $GITHUB_OUTPUT
- echo "exe_url=${EXE_URL}" >> $GITHUB_OUTPUT
- echo "mac_arm64_url=${MAC_ARM64_URL}" >> $GITHUB_OUTPUT
- { echo "body<> $GITHUB_OUTPUT
-
- MAX_BYTES=209715200
- EXE_7Z_SIZE=$(stat -c%s /tmp/release/${EXE_7Z})
- DMG_7Z_SIZE=$(stat -c%s /tmp/release/${DMG_7Z})
-
- {
- echo "file_path_list<> $GITHUB_OUTPUT
-
- {
- echo "file_name_list<> $GITHUB_OUTPUT
-
- - name: Send QQ notification
- uses: H3CoF6/qq-notify-action@50d180981e7c7b8552a3331b981e3f8cfcf40c44
- with:
- appid: ${{ secrets.QQ_APPID }}
- secret: ${{ secrets.QQ_SECRET }}
- group_openid: ${{ secrets.QQ_GROUP_OPENID }}
- message: |
- ## WeChatDataAnalysis 新版本 ${{ steps.prep.outputs.version }} 发布
-
- ==详细更改如下:==
- ${{ steps.prep.outputs.body }}
-
- ---
-
- - Windows 安装包 [下载链接](${{ steps.prep.outputs.exe_url }})
- - macOS arm64 [下载链接](${{ steps.prep.outputs.mac_arm64_url }})
-
- 欢迎大家使用和测试~
- file_path: ${{ steps.prep.outputs.file_path_list }}
- file_type: file
- file_name: ${{ steps.prep.outputs.file_name_list }}
diff --git a/.gitignore b/.gitignore
index 160c0887..fb895e01 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,7 +31,7 @@ wheels/
.ace-tool/
pnpm-lock.yaml
/tools/tmp_isaac64_compare.js
-/native/wce_integrity/
+/native/wce_integrity
/.claude/settings.local.json
.env
.env.*
@@ -87,6 +87,7 @@ pnpm-lock.yaml
/src/wechat_decrypt_tool/native/wechatdb_client.dll
/src/wechat_decrypt_tool/native/wechatdb_broker.exe
/src/wechat_decrypt_tool/native/libwechatdb_client.dylib
+/src/wechat_decrypt_tool/native/libwechatdb_client.so
/src/wechat_decrypt_tool/native/wechatdb_broker
/src/wechat_decrypt_tool/native/wechatdb_native_build.json
/src/wechat_decrypt_tool/native/macos/db-key/
diff --git a/desktop/package.json b/desktop/package.json
index f07fbcd5..6c6744ef 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -20,6 +20,7 @@
"smoke:win:real": "node scripts/smoke-windows-real-database.cjs",
"dist": "npm run dist:win",
"dist:win": "npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --win --x64 --publish never",
+ "dist:linux": "npm run build:ui && npm run build:backend && electron-builder --linux dir --x64 --publish never && node scripts/build-linux-installer.cjs",
"dist:mac": "npm run dist:mac:arm64",
"dist:mac:arm64": "npm run verify:mac:native && npm run build:ui && npm run build:backend && npm run build:icon && electron-builder --mac dmg zip --arm64 --publish never",
"dist:mac:arm64:release": "npm run verify:mac:native && npm run build:ui && npm run build:backend && npm run build:icon && cross-env MACOS_DISTRIBUTION_BUILD=1 electron-builder --mac dmg zip --arm64 --publish never --config.forceCodeSigning=true"
@@ -101,6 +102,14 @@
]
}
],
+ "linux": {
+ "icon": "src/icon.png",
+ "category": "Utility",
+ "executableName": "wechat-data-analysis",
+ "target": [
+ "dir"
+ ]
+ },
"win": {
"icon": "build/icon.ico",
"forceCodeSigning": true,
diff --git a/desktop/scripts/build-backend.cjs b/desktop/scripts/build-backend.cjs
index a0bb2797..82e68b5b 100644
--- a/desktop/scripts/build-backend.cjs
+++ b/desktop/scripts/build-backend.cjs
@@ -1,4 +1,5 @@
const { aiPackagingArgs, runPackagedAiSmoke } = require('./ai-packaging.cjs');
+const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
@@ -11,6 +12,10 @@ const {
macosNativeManifestErrors,
resolveMacosNativeCoreArtifacts,
} = require("./macos-native-core-packaging.cjs");
+const {
+ linuxNativeManifestErrors,
+ resolveLinuxNativeCoreArtifacts,
+} = require("./linux-native-core-packaging.cjs");
const {
resolveIntegrityNativeArtifact,
} = require("./integrity-native-packaging.cjs");
@@ -40,6 +45,8 @@ const NATIVE_CORE_MANIFEST = "wechatdb_native_build.json";
const NATIVE_CORE_ARTIFACTS = Object.freeze({
win32: ["wechatdb_client.dll", "wechatdb_broker.exe", NATIVE_CORE_MANIFEST],
darwin: ["libwechatdb_client.dylib", "wechatdb_broker", NATIVE_CORE_MANIFEST],
+ // Linux 与 macOS 共用同名 broker,客户端是 ELF 共享库;身份靠内容哈希而不是代码签名。
+ linux: ["libwechatdb_client.so", "wechatdb_broker", NATIVE_CORE_MANIFEST],
});
const NATIVE_CORE_FILE_NAMES = new Set(Object.values(NATIVE_CORE_ARTIFACTS).flat());
const LEGACY_WCDB_FILE_NAMES = new Set([
@@ -90,12 +97,15 @@ function nativeCoreManifestErrors(manifest) {
if (!manifest || Array.isArray(manifest) || typeof manifest !== "object") {
return ["manifest must be a JSON object"];
}
- if (!new Set([2, 3]).has(manifest.schemaVersion)) {
- errors.push("schemaVersion must equal 2 or 3");
+ if (!new Set([2, 3, 4]).has(manifest.schemaVersion)) {
+ errors.push("schemaVersion must equal 2, 3 or 4");
}
if (manifest.schemaVersion === 3 && manifest.platform !== "macos") {
errors.push("schemaVersion 3 requires platform macos");
}
+ if (manifest.schemaVersion === 4 && manifest.platform !== "linux") {
+ errors.push("schemaVersion 4 requires platform linux");
+ }
if (manifest.schemaVersion === 2 && Object.prototype.hasOwnProperty.call(manifest, "platform")) {
errors.push("schemaVersion 2 must not declare platform");
}
@@ -145,6 +155,10 @@ function nativeCoreProductionManifestErrors(
if (manifest?.schemaVersion === 3) {
return macosNativeManifestErrors(manifest, { nowUnix });
}
+ // schema v4 是 Linux 的完整契约(含内容哈希 pin 与 45 天窗口),不能走下面 Windows 那套。
+ if (manifest?.schemaVersion === 4) {
+ return linuxNativeManifestErrors(manifest, { nowUnix });
+ }
const errors = nativeCoreManifestErrors(manifest);
const buildIssuedAtUnix = manifest?.buildIssuedAtUnix;
const buildExpiresAtUnix = manifest?.buildExpiresAtUnix;
@@ -271,6 +285,11 @@ function resolveNativeCoreArtifacts({ env = process.env, platform = process.plat
return { ...resolved, allowDevelopment: false, required: true };
}
+ if (platform === "linux" && !allowDevelopment) {
+ const resolved = resolveLinuxNativeCoreArtifacts({ env, platform });
+ return { ...resolved, allowDevelopment: false, required: true };
+ }
+
const artifactDir = path.resolve(explicitValue);
let directoryStat;
try {
@@ -370,6 +389,17 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat
}
const integrityTargetDir = path.join(repoRoot, "native", "wce_integrity", "target", "release");
const fileName = platform === "darwin" ? "libwce_integrity.dylib" : "libwce_integrity.so";
+ // 构建密钥 = 编译 wce_integrity 时注入的 P-256 私钥(WCE_SIGNING_KEY_HEX),只用来给导出物封签,
+ // 公钥随模块一起编译进去,没有任何外部预注册,所以「每次构建现生成一把」是安全的。
+ // 这与 Windows 官方入口 tools/build_wce_integrity.ps1 -GenerateEphemeralSigningKey 语义一致:
+ // 有注入就用注入的(可复现),没注入就现生成一把临时的(Linux/macOS 本地构建的默认)。
+ const providedSigningKey = String(env.WCE_SIGNING_KEY_HEX || "").trim();
+ const signingKeyHex = providedSigningKey || crypto.randomBytes(32).toString("hex");
+ if (!providedSigningKey) {
+ process.stdout.write(
+ `wce_integrity: generated an ephemeral build signing key for ${platform} (set WCE_SIGNING_KEY_HEX to pin it)\n`
+ );
+ }
const result = spawnSync(
"cargo",
["build", "--manifest-path", integrityManifest, "--release"],
@@ -377,6 +407,7 @@ function buildIntegrityNativeBinary({ env = process.env, platform = process.plat
cwd: repoRoot,
env: {
...env,
+ WCE_SIGNING_KEY_HEX: signingKeyHex,
WCE_UI_PUBLIC_DIR: path.join(repoRoot, "frontend", ".output", "public"),
},
stdio: "inherit",
diff --git a/desktop/scripts/build-linux-installer.cjs b/desktop/scripts/build-linux-installer.cjs
new file mode 100644
index 00000000..536edcf3
--- /dev/null
+++ b/desktop/scripts/build-linux-installer.cjs
@@ -0,0 +1,185 @@
+"use strict";
+
+// 把 electron-builder 的 Linux 解包产物(dist/linux-unpacked)打成「一键安装」素材:
+//
+// dist/WeChatDataAnalysis--linux-x86_64.tar.gz 负载
+// dist/install.sh 一键安装/卸载脚本(内嵌负载 SHA-256)
+// dist/SHA256SUMS.txt 给人工核对用
+//
+// 刻意不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg,
+// Linux 走「用户级、免 root 的 tar.gz + install.sh」这条路。
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const desktopRoot = path.resolve(__dirname, "..");
+const DEFAULT_PAYLOAD_DIR = path.join(desktopRoot, "dist", "linux-unpacked");
+const DEFAULT_OUTPUT_DIR = path.join(desktopRoot, "dist");
+const TEMPLATE_PATH = path.join(__dirname, "linux-installer-template.sh");
+const ICON_SOURCE = path.join(desktopRoot, "src", "icon.png");
+const ICON_NAME = "wechat-data-analysis.png";
+const ARCH = "x86_64";
+
+function readPackageMetadata() {
+ const packageJson = JSON.parse(
+ fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8")
+ );
+ const productName = String(packageJson.build?.productName || packageJson.name || "").trim();
+ const version = String(packageJson.version || "").trim();
+ const executableName = String(packageJson.build?.linux?.executableName || "").trim();
+ if (!productName || !version || !executableName) {
+ throw new Error(
+ "package.json must declare build.productName, version and build.linux.executableName"
+ );
+ }
+ return { productName, version, executableName };
+}
+
+function sha256File(filePath) {
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+}
+
+function runTarCreate(payloadDir, archivePath) {
+ // 用系统 tar 而不是 Node 第三方库:保留权限位/符号链接,且 CI 与本机一致。
+ const result = spawnSync("tar", ["-czf", archivePath, "-C", payloadDir, "."], {
+ stdio: "inherit",
+ });
+ if (result.error) throw result.error;
+ if ((result.status ?? 1) !== 0) {
+ throw new Error(`tar failed with exit code ${result.status}`);
+ }
+}
+
+function renderInstallerTemplate({ productName, version, executableName, payloadName, sha256 }) {
+ const template = fs.readFileSync(TEMPLATE_PATH, "utf8");
+ const replacements = {
+ "@@PRODUCT@@": productName,
+ "@@VERSION@@": version,
+ "@@ARCH@@": ARCH,
+ "@@EXECUTABLE@@": executableName,
+ "@@PAYLOAD@@": payloadName,
+ "@@SHA256@@": sha256,
+ };
+ let rendered = template;
+ for (const [token, value] of Object.entries(replacements)) {
+ rendered = rendered.split(token).join(value);
+ }
+ const leftover = rendered.match(/@@[A-Z_]+@@/);
+ if (leftover) throw new Error(`installer template still contains ${leftover[0]}`);
+ return rendered;
+}
+
+function buildLinuxInstaller({
+ payloadDir = DEFAULT_PAYLOAD_DIR,
+ outputDir = DEFAULT_OUTPUT_DIR,
+ metadata = readPackageMetadata(),
+ skipArchive = false,
+} = {}) {
+ const { productName, version, executableName } = metadata;
+ const payloadStat = (() => {
+ try {
+ return fs.statSync(payloadDir);
+ } catch {
+ throw new Error(`Linux payload directory not found: ${payloadDir}`);
+ }
+ })();
+ if (!payloadStat.isDirectory()) {
+ throw new Error(`Linux payload is not a directory: ${payloadDir}`);
+ }
+ const executable = path.join(payloadDir, executableName);
+ try {
+ const stat = fs.statSync(executable);
+ if (!stat.isFile()) throw new Error("not a file");
+ } catch {
+ throw new Error(
+ `Linux payload is missing the application executable: ${executable}. ` +
+ "Run `npm run dist:linux` first."
+ );
+ }
+
+ // 桌面项要用的图标随包一起走,避免安装后引用仓库里的路径。
+ const iconDestination = path.join(payloadDir, "resources", ICON_NAME);
+ fs.mkdirSync(path.dirname(iconDestination), { recursive: true });
+ fs.copyFileSync(ICON_SOURCE, iconDestination);
+
+ fs.mkdirSync(outputDir, { recursive: true });
+ const payloadName = `${productName}-${version}-linux-${ARCH}.tar.gz`;
+ const archivePath = path.join(outputDir, payloadName);
+ if (!skipArchive) {
+ fs.rmSync(archivePath, { force: true });
+ runTarCreate(payloadDir, archivePath);
+ }
+ if (!fs.existsSync(archivePath)) {
+ throw new Error(`Linux payload archive was not produced: ${archivePath}`);
+ }
+ const digest = sha256File(archivePath);
+
+ const installerPath = path.join(outputDir, "install.sh");
+ fs.writeFileSync(
+ installerPath,
+ renderInstallerTemplate({
+ productName,
+ version,
+ executableName,
+ payloadName,
+ sha256: digest,
+ }),
+ { mode: 0o755 }
+ );
+ fs.chmodSync(installerPath, 0o755);
+
+ const checksumsPath = path.join(outputDir, "SHA256SUMS.txt");
+ fs.writeFileSync(
+ checksumsPath,
+ `${digest} ${payloadName}\n${sha256File(installerPath)} install.sh\n`
+ );
+
+ return { archivePath, installerPath, checksumsPath, payloadName, sha256: digest };
+}
+
+function parseCliArguments(argv) {
+ const options = {};
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index];
+ if (argument === "--payload-dir") options.payloadDir = path.resolve(argv[++index]);
+ else if (argument === "--output-dir") options.outputDir = path.resolve(argv[++index]);
+ else if (argument === "--skip-archive") options.skipArchive = true;
+ else if (argument === "--help" || argument === "-h") options.help = true;
+ else throw new Error(`Unknown argument: ${argument}`);
+ }
+ return options;
+}
+
+function main(argv = process.argv.slice(2)) {
+ const options = parseCliArguments(argv);
+ if (options.help) {
+ process.stdout.write(
+ "Usage: node scripts/build-linux-installer.cjs [--payload-dir DIR] [--output-dir DIR] [--skip-archive]\n"
+ );
+ return 0;
+ }
+ const result = buildLinuxInstaller(options);
+ process.stdout.write(`Linux payload: ${result.archivePath}\n`);
+ process.stdout.write(`Installer: ${result.installerPath}\n`);
+ process.stdout.write(`SHA-256: ${result.sha256}\n`);
+ return 0;
+}
+
+if (require.main === module) {
+ try {
+ process.exitCode = main();
+ } catch (error) {
+ process.stderr.write(`${error?.message || error}\n`);
+ process.exitCode = 1;
+ }
+}
+
+module.exports = {
+ ARCH,
+ buildLinuxInstaller,
+ parseCliArguments,
+ readPackageMetadata,
+ renderInstallerTemplate,
+};
diff --git a/desktop/scripts/linux-installer-template.sh b/desktop/scripts/linux-installer-template.sh
new file mode 100644
index 00000000..9f8eee69
--- /dev/null
+++ b/desktop/scripts/linux-installer-template.sh
@@ -0,0 +1,162 @@
+#!/bin/sh
+# @@PRODUCT@@ @@VERSION@@ (linux-@@ARCH@@) 一键安装脚本 —— 由 Build-LinuxInstaller 生成,请勿手改。
+#
+# 设计取舍(Linux 没有安装包是刻意的):
+# * 不做 AppImage / deb:作者的分发形态只有 Windows 安装包与 macOS dmg。
+# * 所以这里给一个「用户级、免 root」的安装脚本:解包到用户目录 + 桌面项 + 启动器。
+# * 产物身份靠内容哈希:脚本里内嵌 tarball 的 SHA-256,装之前先校验。
+#
+# 用法:
+# ./install.sh # 装到 ${XDG_DATA_HOME:-~/.local/share}/wechat-data-analysis
+# ./install.sh --prefix /opt/x # 自定义前缀
+# ./install.sh --uninstall # 卸载
+set -eu
+
+PRODUCT='@@PRODUCT@@'
+VERSION='@@VERSION@@'
+ARCH='@@ARCH@@'
+PAYLOAD_NAME='@@PAYLOAD@@'
+PAYLOAD_SHA256='@@SHA256@@'
+
+DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
+BIN_HOME="${XDG_BIN_HOME:-$HOME/.local/bin}"
+DEFAULT_PREFIX="$DATA_HOME/wechat-data-analysis"
+PREFIX="$DEFAULT_PREFIX"
+UNINSTALL=0
+
+die() { printf '错误: %s\n' "$1" >&2; exit 1; }
+info() { printf '%s\n' "$1"; }
+
+usage() {
+ cat </dev/null 2>&1; then
+ actual=$(sha256sum "$PAYLOAD" | awk '{print $1}')
+ elif command -v shasum >/dev/null 2>&1; then
+ actual=$(shasum -a 256 "$PAYLOAD" | awk '{print $1}')
+ else
+ die "找不到 sha256sum 或 shasum,无法校验安装包完整性"
+ fi
+ [ "$actual" = "$PAYLOAD_SHA256" ] || die "安装包校验失败:期望 $PAYLOAD_SHA256,实际 $actual"
+}
+
+verify_hash
+info "校验通过: $PAYLOAD_NAME"
+
+TARGET="$PREFIX/$VERSION"
+[ "$TARGET" != "$PREFIX" ] || die "安装目标解析异常: $TARGET"
+
+mkdir -p "$PREFIX" "$BIN_HOME"
+STAGING="$PREFIX/.staging-$$"
+rm -rf "$STAGING"
+mkdir -p "$STAGING"
+
+cleanup() { rm -rf "$STAGING"; }
+trap cleanup EXIT HUP INT TERM
+
+info "解包到 $TARGET ..."
+tar -xzf "$PAYLOAD" -C "$STAGING" || die "解包失败"
+[ -x "$STAGING/@@EXECUTABLE@@" ] || die "安装包里找不到可执行文件 @@EXECUTABLE@@"
+
+rm -rf "$TARGET"
+# staging 与 TARGET 同处 $PREFIX 下,rename 是原子的:不会留下半新半旧的目录。
+mv "$STAGING" "$TARGET"
+cleanup
+trap - EXIT HUP INT TERM
+
+# current 是原子切换的指针,升级时不会留下半新半旧的目录。
+ln -sfn "$TARGET" "$PREFIX/current"
+
+LAUNCHER="$PREFIX/bin/wechat-data-analysis"
+mkdir -p "$PREFIX/bin"
+cat > "$LAUNCHER" < "$DESKTOP_FILE"
+chmod 0644 "$DESKTOP_FILE"
+
+info "已安装: $TARGET"
+info "启动器: $LAUNCHER"
+info "桌面项: $DESKTOP_FILE"
+case ":$PATH:" in
+ *":$BIN_HOME:"*) info "命令行可用: wechat-data-analysis" ;;
+ *) info "提示: 把 $BIN_HOME 加进 PATH 后可直接用 wechat-data-analysis" ;;
+esac
+
+# Electron 在 Linux 上依赖「非特权用户命名空间」来开沙箱;内核关掉它时应用会起不来。
+# 这里只做提示,不替用户改内核参数,也不默认加 --no-sandbox(那会削弱沙箱)。
+if [ -r /proc/sys/user/max_user_namespaces ] && [ "$(cat /proc/sys/user/max_user_namespaces)" = "0" ]; then
+ info "警告: 当前内核禁用了非特权用户命名空间,Electron 沙箱无法启动。"
+ info " 可用 sysctl user.max_user_namespaces=10000 打开,或自行以 --no-sandbox 运行(不推荐)。"
+fi
+
+info "卸载: $SCRIPT_DIR/install.sh --uninstall --prefix $PREFIX"
diff --git a/desktop/scripts/linux-native-core-packaging.cjs b/desktop/scripts/linux-native-core-packaging.cjs
new file mode 100644
index 00000000..9e2604c3
--- /dev/null
+++ b/desktop/scripts/linux-native-core-packaging.cjs
@@ -0,0 +1,502 @@
+"use strict";
+
+// Linux 的 native core 消费校验。
+//
+// 与 macOS 那套(macos-native-core-packaging.cjs)对齐,但签名模型完全不同:
+// Linux 没有代码签名,产物身份 = **内容哈希**(linuxIntegrityMode: content-hash-pin)。
+// 所以这里把 manifest 里的 linuxClientSha256 / linuxBrokerSha256 当成身份声明,
+// 逐字节比对实际文件,再用 SHA256SUMS.txt + provenance.json 把来源钉到某个 WCDB revision。
+// 一旦内容被替换,哈希必然对不上,直接 fail closed。
+
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const path = require("node:path");
+
+const CLIENT_NAME = "libwechatdb_client.so";
+const BROKER_NAME = "wechatdb_broker";
+const MANIFEST_NAME = "wechatdb_native_build.json";
+const CHECKSUMS_NAME = "SHA256SUMS.txt";
+const PROVENANCE_NAME = "provenance.json";
+const ARTIFACT_TEST_NAME = "Test-LinuxNativeProductionArtifact.py";
+
+const BUILD_LIFETIME_SECONDS = 45 * 24 * 60 * 60;
+const SHA256_PATTERN = /^[0-9a-f]{64}$/;
+const BUILD_ID_PATTERN = /^[A-Za-z0-9._-]{8,128}$/;
+const REVISION_PATTERN = /^[0-9a-f]{40}$/;
+const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
+const NON_PRODUCTION_BUILD_ID_PATTERN =
+ /(^|[._-])(dev|debug|test|local|snapshot|staging)([._-]|$)/i;
+
+const INTEGRITY_MODE = "content-hash-pin";
+// 产出这份产物的工作流路径。它也是身份的一部分:只有被审阅过的 producer 才允许
+// 产出发布路径会接受的产物(artifact 内部的 Python 校验器断言同一个常量)。
+const PRODUCER_WORKFLOW = ".github/workflows/linux-native-production.yml";
+const PEER_VERIFICATION = "same-user-peer-credentials";
+const HOST_VERIFICATION = Object.freeze({
+ production: "content-hash-pin",
+ sourceRuntime: "same-user-direct-parent",
+});
+const ARTIFACT_NAME_PATTERN = /^wechatdb-native-linux-x64-(production|source-public)$/;
+const PRODUCERS = new Set(["github-actions", "manual"]);
+
+// 校验集只覆盖「运行时真正要用的四个文件」;SHA256SUMS.txt / provenance.json 是自证材料。
+const CHECKSUM_FILE_NAMES = Object.freeze([
+ ARTIFACT_TEST_NAME,
+ CLIENT_NAME,
+ BROKER_NAME,
+ MANIFEST_NAME,
+]);
+const ARTIFACT_FILE_NAMES = Object.freeze([
+ ...CHECKSUM_FILE_NAMES,
+ CHECKSUMS_NAME,
+ PROVENANCE_NAME,
+]);
+const RUNTIME_FILE_NAMES = Object.freeze([CLIENT_NAME, BROKER_NAME, MANIFEST_NAME]);
+
+const MANIFEST_REQUIRED_FIELDS = Object.freeze([
+ "schemaVersion",
+ "platform",
+ "distributionMode",
+ "buildId",
+ "buildIssuedAtUnix",
+ "buildExpiresAtUnix",
+ "developmentBuild",
+ "offlineBootstrapFeatureBits",
+ "offlineExportSealFormat",
+ "codeSignatureEnforced",
+ "rootPublicKeyCompiled",
+ "testHooksEnabled",
+ "stagingPinnedSignerTrust",
+ "linuxIntegrityMode",
+ "linuxClientSha256",
+ "linuxBrokerSha256",
+ "linuxPeerVerification",
+ "linuxHostVerification",
+ "securityNoticeId",
+ "securityNoticeSha256",
+ "securityCheckpointSetId",
+ "securityCheckpointCount",
+ "securityCheckpointSetSha256",
+]);
+const MANIFEST_OPTIONAL_FIELDS = Object.freeze(["sourceRuntime"]);
+const PROVENANCE_FIELDS = Object.freeze([
+ "schemaVersion",
+ "artifactName",
+ "producer",
+ "workflow",
+ "repository",
+ "runId",
+ "runAttempt",
+ "sourceRevision",
+ "build",
+ "manifestSha256",
+ "checksumsSha256",
+ "artifacts",
+]);
+
+function exactKeys(value, required, optional = []) {
+ if (!value || Array.isArray(value) || typeof value !== "object") return false;
+ const allowed = new Set([...required, ...optional]);
+ const actual = Object.keys(value);
+ if (actual.some((name) => !allowed.has(name))) return false;
+ return required.every((name) => Object.prototype.hasOwnProperty.call(value, name));
+}
+
+function sha256File(filePath) {
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
+}
+
+function isNonZeroSha256(value) {
+ const text = String(value || "");
+ return SHA256_PATTERN.test(text) && !/^0{64}$/.test(text);
+}
+
+function readJson(filePath, label, maximum = 64 * 1024) {
+ try {
+ const stat = fs.statSync(filePath);
+ if (!stat.isFile() || stat.size <= 0 || stat.size > maximum) throw new Error("invalid size");
+ const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
+ if (!value || Array.isArray(value) || typeof value !== "object") {
+ throw new Error("root must be an object");
+ }
+ return value;
+ } catch (error) {
+ throw new Error(`Invalid ${label} at ${filePath}: ${error.message}`);
+ }
+}
+
+function requiredEnv(env, name, pattern) {
+ const value = String(env[name] || "").trim();
+ if (!value || (pattern && !pattern.test(value))) {
+ throw new Error(`Missing or invalid ${name}`);
+ }
+ return value;
+}
+
+function optionalEnvPin(env, name) {
+ const value = String(env[name] || "").trim();
+ if (!value) return null;
+ if (!isNonZeroSha256(value)) {
+ throw new Error(`${name} must be a non-zero lowercase SHA-256 digest`);
+ }
+ return value;
+}
+
+function parseChecksums(filePath) {
+ const records = new Map();
+ const lines = fs.readFileSync(filePath, "utf8").split(/\r?\n/).filter(Boolean);
+ for (const line of lines) {
+ const match = /^([0-9a-f]{64}) {2}([A-Za-z0-9._-]+)$/.exec(line);
+ if (!match || records.has(match[2])) throw new Error("SHA256SUMS.txt has an invalid record");
+ records.set(match[2], match[1]);
+ }
+ return records;
+}
+
+function linuxNativeManifestErrors(manifest, { nowUnix = Math.floor(Date.now() / 1000) } = {}) {
+ const errors = [];
+ if (!exactKeys(manifest, MANIFEST_REQUIRED_FIELDS, MANIFEST_OPTIONAL_FIELDS)) {
+ errors.push("manifest fields must match Linux schema v4 exactly");
+ return errors;
+ }
+ if (manifest.schemaVersion !== 4) errors.push("schemaVersion must equal 4");
+ if (manifest.platform !== "linux") errors.push("platform must equal linux");
+ if (manifest.distributionMode !== "public") errors.push("distributionMode must equal public");
+ if (
+ !BUILD_ID_PATTERN.test(String(manifest.buildId || "")) ||
+ NON_PRODUCTION_BUILD_ID_PATTERN.test(String(manifest.buildId || ""))
+ ) {
+ errors.push("buildId must be an immutable production identity");
+ }
+ const issued = manifest.buildIssuedAtUnix;
+ const expires = manifest.buildExpiresAtUnix;
+ if (
+ !Number.isSafeInteger(issued) ||
+ issued <= 0 ||
+ !Number.isSafeInteger(expires) ||
+ expires !== issued + BUILD_LIFETIME_SECONDS
+ ) {
+ errors.push("build validity window must equal exactly 45 days");
+ } else if (!Number.isSafeInteger(nowUnix) || nowUnix < 0 || nowUnix >= expires) {
+ errors.push("build has reached its fixed expiration time");
+ }
+ if (
+ manifest.developmentBuild !== false ||
+ manifest.offlineBootstrapFeatureBits !== 3 ||
+ manifest.offlineExportSealFormat !== "WES2" ||
+ manifest.codeSignatureEnforced !== true ||
+ manifest.rootPublicKeyCompiled !== true ||
+ manifest.testHooksEnabled !== false ||
+ manifest.stagingPinnedSignerTrust !== false
+ ) {
+ errors.push("native production security fields do not match policy");
+ }
+ if (manifest.linuxIntegrityMode !== INTEGRITY_MODE) {
+ errors.push(`linuxIntegrityMode must equal ${INTEGRITY_MODE}`);
+ }
+ if (manifest.linuxPeerVerification !== PEER_VERIFICATION) {
+ errors.push(`linuxPeerVerification must equal ${PEER_VERIFICATION}`);
+ }
+ // 两个 profile 的 host 校验强度不同,必须自洽:源码分发用「直接父进程」,
+ // 否则用「内容哈希 pin」。声明 sourceRuntime 就只能是前者。
+ const sourceRuntime = manifest.sourceRuntime === true;
+ if (
+ Object.prototype.hasOwnProperty.call(manifest, "sourceRuntime") &&
+ manifest.sourceRuntime !== true
+ ) {
+ errors.push("sourceRuntime must be true when present");
+ }
+ const expectedHostVerification = sourceRuntime
+ ? HOST_VERIFICATION.sourceRuntime
+ : HOST_VERIFICATION.production;
+ if (manifest.linuxHostVerification !== expectedHostVerification) {
+ errors.push(`linuxHostVerification must equal ${expectedHostVerification}`);
+ }
+ const pins = [manifest.linuxClientSha256, manifest.linuxBrokerSha256];
+ if (pins.some((value) => !isNonZeroSha256(value))) {
+ errors.push("linux client and broker content pins must be non-zero SHA-256 digests");
+ } else if (pins[0] === pins[1]) {
+ errors.push("linux client and broker content pins must be distinct");
+ }
+ if (
+ manifest.securityNoticeId !== "WCE-AUTOMATED-ANALYSIS-NOTICE-V2" ||
+ !SHA256_PATTERN.test(String(manifest.securityNoticeSha256 || "")) ||
+ manifest.securityCheckpointSetId !== "WCE-AI-CHECKPOINT-SET-V3" ||
+ manifest.securityCheckpointCount !== 7 ||
+ !SHA256_PATTERN.test(String(manifest.securityCheckpointSetSha256 || ""))
+ ) {
+ errors.push("native security checkpoint contract mismatch");
+ }
+ return errors;
+}
+
+// 内容哈希就是 Linux 的身份。manifest 声明什么,盘上就必须是什么。
+function linuxContentPinErrors({ directory, manifest }) {
+ const errors = [];
+ const expectations = [
+ [CLIENT_NAME, manifest?.linuxClientSha256],
+ [BROKER_NAME, manifest?.linuxBrokerSha256],
+ ];
+ for (const [name, expected] of expectations) {
+ const filePath = path.join(directory, name);
+ try {
+ if (!fs.statSync(filePath).isFile()) throw new Error("not a regular file");
+ } catch {
+ errors.push(`missing native component ${name}`);
+ continue;
+ }
+ const actual = sha256File(filePath);
+ if (actual !== expected) {
+ errors.push(`content hash mismatch for ${name}: expected ${expected}, received ${actual}`);
+ }
+ }
+ return errors;
+}
+
+// 极简 ELF 头解析:不依赖 readelf/file,Linux 与 macOS 主机上都能跑。
+// 只断言「身份声明」需要的部分:64 位、小端、x86-64、以及可执行类别。
+function inspectElf(filePath) {
+ const header = Buffer.alloc(20);
+ const handle = fs.openSync(filePath, "r");
+ try {
+ fs.readSync(handle, header, 0, 20, 0);
+ } finally {
+ fs.closeSync(handle);
+ }
+ if (!header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))) {
+ throw new Error(`not an ELF file: ${filePath}`);
+ }
+ const elfClass = header[4];
+ const dataEncoding = header[5];
+ if (elfClass !== 2) throw new Error(`ELF is not 64-bit: ${filePath}`);
+ if (dataEncoding !== 1) throw new Error(`ELF is not little-endian: ${filePath}`);
+ const type = header.readUInt16LE(16);
+ const machine = header.readUInt16LE(18);
+ if (machine !== 0x3e) throw new Error(`ELF is not x86-64: ${filePath}`);
+ return { type, machine, isSharedObject: type === 3, isExecutable: type === 2 || type === 3 };
+}
+
+function resolveLinuxNativeCoreArtifacts({
+ env = process.env,
+ platform = process.platform,
+ nowUnix = Math.floor(Date.now() / 1000),
+} = {}) {
+ if (platform !== "linux") {
+ throw new Error(`Linux native-core artifacts cannot be resolved on platform: ${platform}`);
+ }
+ const artifactDirValue = String(env.WCE_NATIVE_CORE_ARTIFACT_DIR || "").trim();
+ if (!artifactDirValue) {
+ throw new Error(
+ "Missing WCE_NATIVE_CORE_ARTIFACT_DIR. Expected a directory containing: " +
+ RUNTIME_FILE_NAMES.join(", ")
+ );
+ }
+ const artifactDir = path.resolve(artifactDirValue);
+ let stat;
+ try {
+ stat = fs.statSync(artifactDir);
+ } catch {
+ throw new Error(`WCE_NATIVE_CORE_ARTIFACT_DIR is not readable: ${artifactDir}`);
+ }
+ if (!stat.isDirectory()) {
+ throw new Error(`WCE_NATIVE_CORE_ARTIFACT_DIR is not a directory: ${artifactDir}`);
+ }
+ const entries = fs.readdirSync(artifactDir, { withFileTypes: true });
+ const files = entries
+ .filter((entry) => entry.isFile())
+ .map((entry) => entry.name)
+ .sort();
+ const wanted = [...ARTIFACT_FILE_NAMES].sort();
+ if (entries.some((entry) => !entry.isFile()) || files.join("\n") !== wanted.join("\n")) {
+ throw new Error(
+ `Linux native-core artifact allowlist mismatch. Expected ${wanted.join(", ")}, received ${files.join(", ")}`
+ );
+ }
+
+ const repository = requiredEnv(env, "WCE_NATIVE_CORE_ARTIFACT_REPOSITORY", REPOSITORY_PATTERN);
+ const sourceRevision = requiredEnv(env, "WCE_NATIVE_CORE_SOURCE_REVISION", REVISION_PATTERN);
+ const buildId = requiredEnv(env, "WCE_NATIVE_CORE_BUILD_ID", BUILD_ID_PATTERN);
+ const clientPin = optionalEnvPin(env, "WCE_NATIVE_CORE_CLIENT_SHA256");
+ const brokerPin = optionalEnvPin(env, "WCE_NATIVE_CORE_BROKER_SHA256");
+
+ const manifestPath = path.join(artifactDir, MANIFEST_NAME);
+ const manifest = readJson(manifestPath, "Linux native-core manifest", 16 * 1024);
+ const manifestErrors = linuxNativeManifestErrors(manifest, { nowUnix });
+ if (manifestErrors.length > 0) {
+ throw new Error(`Refusing Linux native-core artifact: ${manifestErrors.join("; ")}`);
+ }
+ if (manifest.buildId !== buildId) {
+ throw new Error("Linux native-core manifest does not match the protected build id pin");
+ }
+ if (clientPin && manifest.linuxClientSha256 !== clientPin) {
+ throw new Error("Linux native-core manifest does not match the protected client content pin");
+ }
+ if (brokerPin && manifest.linuxBrokerSha256 !== brokerPin) {
+ throw new Error("Linux native-core manifest does not match the protected broker content pin");
+ }
+
+ const checksumsPath = path.join(artifactDir, CHECKSUMS_NAME);
+ const checksums = parseChecksums(checksumsPath);
+ if (
+ checksums.size !== CHECKSUM_FILE_NAMES.length ||
+ CHECKSUM_FILE_NAMES.some(
+ (name) => checksums.get(name) !== sha256File(path.join(artifactDir, name))
+ )
+ ) {
+ throw new Error("Linux native-core checksum set does not match the artifact allowlist");
+ }
+
+ const provenance = readJson(path.join(artifactDir, PROVENANCE_NAME), "Linux native-core provenance");
+ if (!exactKeys(provenance, PROVENANCE_FIELDS)) {
+ throw new Error("Linux native-core provenance fields do not match schema v1 exactly");
+ }
+ const producer = String(provenance.producer || "");
+ if (!PRODUCERS.has(producer)) {
+ throw new Error("Linux native-core provenance must come from github-actions or a manual producer");
+ }
+ if (provenance.schemaVersion !== 1) {
+ throw new Error("Linux native-core provenance schemaVersion must equal 1");
+ }
+ if (!ARTIFACT_NAME_PATTERN.test(String(provenance.artifactName || ""))) {
+ throw new Error("Linux native-core provenance artifactName is not a Linux x64 profile");
+ }
+ if (provenance.repository !== repository) {
+ throw new Error("Linux native-core provenance repository does not match the protected pin");
+ }
+ if (provenance.sourceRevision !== sourceRevision) {
+ throw new Error("Linux native-core provenance revision does not match the protected pin");
+ }
+ const expectedRunId = String(env.WCE_NATIVE_CORE_ARTIFACT_RUN_ID || "").trim();
+ if (producer === "github-actions") {
+ if (!/^[1-9][0-9]*$/.test(expectedRunId) || Number(provenance.runId) !== Number(expectedRunId)) {
+ throw new Error("Linux native-core provenance run id does not match the protected pin");
+ }
+ if (!Number.isSafeInteger(provenance.runAttempt) || provenance.runAttempt <= 0) {
+ throw new Error("Linux native-core provenance runAttempt must be a positive integer");
+ }
+ if (provenance.workflow !== PRODUCER_WORKFLOW) {
+ throw new Error(
+ `Linux native-core provenance must come from ${PRODUCER_WORKFLOW}`
+ );
+ }
+ } else {
+ if (expectedRunId !== "" || provenance.runId !== 0 || provenance.runAttempt !== 0) {
+ throw new Error("Manual Linux native-core provenance must not claim a CI run");
+ }
+ if (String(provenance.workflow || "") !== "manual") {
+ throw new Error("Manual Linux native-core provenance must declare workflow manual");
+ }
+ }
+ if (provenance.manifestSha256 !== sha256File(manifestPath)) {
+ throw new Error("Linux native-core provenance manifest hash mismatch");
+ }
+ if (provenance.checksumsSha256 !== sha256File(checksumsPath)) {
+ throw new Error("Linux native-core provenance checksums hash mismatch");
+ }
+ const expectedInventory = CHECKSUM_FILE_NAMES.map((name) => ({
+ path: name,
+ sha256: sha256File(path.join(artifactDir, name)),
+ size: fs.statSync(path.join(artifactDir, name)).size,
+ }));
+ if (JSON.stringify(provenance.artifacts) !== JSON.stringify(expectedInventory)) {
+ throw new Error("Linux native-core provenance artifact inventory mismatch");
+ }
+ const build = provenance.build;
+ // linuxHostVerification / sourceRuntime 只出现在源码分发(-sp)那份 provenance 里,
+ // 所以它们是「可选的,但出现就必须与 manifest 一致」。
+ if (
+ !exactKeys(
+ build,
+ [
+ "architecture",
+ "distributionMode",
+ "expiresAtUnix",
+ "id",
+ "integrityMode",
+ "issuedAtUnix",
+ "linuxBrokerSha256",
+ "linuxClientSha256",
+ "offlineBootstrapFeatureBits",
+ "offlineExportSealFormat",
+ "platform",
+ "readOnlyBuild",
+ "securityCheckpointCount",
+ "securityCheckpointSetId",
+ "securityCheckpointSetSha256",
+ "securityNoticeId",
+ "securityNoticeSha256",
+ ],
+ ["linuxHostVerification", "sourceRuntime"]
+ ) ||
+ build.id !== manifest.buildId ||
+ build.platform !== "linux" ||
+ build.architecture !== "x64" ||
+ build.distributionMode !== manifest.distributionMode ||
+ build.integrityMode !== manifest.linuxIntegrityMode ||
+ build.readOnlyBuild !== true ||
+ build.issuedAtUnix !== manifest.buildIssuedAtUnix ||
+ build.expiresAtUnix !== manifest.buildExpiresAtUnix ||
+ build.linuxClientSha256 !== manifest.linuxClientSha256 ||
+ build.linuxBrokerSha256 !== manifest.linuxBrokerSha256 ||
+ build.offlineBootstrapFeatureBits !== manifest.offlineBootstrapFeatureBits ||
+ build.offlineExportSealFormat !== manifest.offlineExportSealFormat ||
+ build.securityCheckpointCount !== manifest.securityCheckpointCount ||
+ build.securityCheckpointSetId !== manifest.securityCheckpointSetId ||
+ build.securityCheckpointSetSha256 !== manifest.securityCheckpointSetSha256 ||
+ build.securityNoticeId !== manifest.securityNoticeId ||
+ build.securityNoticeSha256 !== manifest.securityNoticeSha256
+ ) {
+ throw new Error("Linux native-core provenance build record does not match the manifest");
+ }
+ if (
+ (Object.prototype.hasOwnProperty.call(build, "linuxHostVerification") &&
+ build.linuxHostVerification !== manifest.linuxHostVerification) ||
+ (Object.prototype.hasOwnProperty.call(build, "sourceRuntime") &&
+ build.sourceRuntime !== manifest.sourceRuntime)
+ ) {
+ throw new Error("Linux native-core provenance build record does not match the manifest");
+ }
+
+ const pinErrors = linuxContentPinErrors({ directory: artifactDir, manifest });
+ if (pinErrors.length > 0) {
+ throw new Error(`Refusing Linux native-core artifact: ${pinErrors.join("; ")}`);
+ }
+ const clientElf = inspectElf(path.join(artifactDir, CLIENT_NAME));
+ const brokerElf = inspectElf(path.join(artifactDir, BROKER_NAME));
+ if (!clientElf.isSharedObject) {
+ throw new Error("Linux native client must be an x86-64 ELF shared object");
+ }
+ if (!brokerElf.isExecutable) {
+ throw new Error("Linux native broker must be an x86-64 ELF executable");
+ }
+
+ return {
+ artifactDir,
+ manifest,
+ provenance,
+ repository,
+ sourceRevision,
+ buildId,
+ clientPin: manifest.linuxClientSha256,
+ brokerPin: manifest.linuxBrokerSha256,
+ names: [...RUNTIME_FILE_NAMES],
+ required: true,
+ };
+}
+
+module.exports = {
+ ARTIFACT_FILE_NAMES,
+ BROKER_NAME,
+ CHECKSUM_FILE_NAMES,
+ CLIENT_NAME,
+ HOST_VERIFICATION,
+ INTEGRITY_MODE,
+ MANIFEST_NAME,
+ PEER_VERIFICATION,
+ PRODUCER_WORKFLOW,
+ RUNTIME_FILE_NAMES,
+ inspectElf,
+ linuxContentPinErrors,
+ linuxNativeManifestErrors,
+ resolveLinuxNativeCoreArtifacts,
+};
diff --git a/desktop/scripts/native-core-before-pack.cjs b/desktop/scripts/native-core-before-pack.cjs
index 0137f44e..f728f0a8 100644
--- a/desktop/scripts/native-core-before-pack.cjs
+++ b/desktop/scripts/native-core-before-pack.cjs
@@ -9,6 +9,9 @@ const {
const {
assertWindowsNativeAsrCapability,
} = require("../src/windows-native-asr-capability.cjs");
+const {
+ linuxContentPinErrors,
+} = require("./linux-native-core-packaging.cjs");
const desktopRoot = path.resolve(__dirname, "..");
const LEGACY_WCDB_PATHS = [
@@ -185,6 +188,13 @@ function validatePackagedBackend({
if (platform === "win32") {
assertWindowsNativeAsrCapability({ nativeDir, manifest });
}
+ if (platform === "linux") {
+ // 打包后再验一次「内容哈希 pin」:这是 Linux 唯一的产物身份,必须逐字节站得住。
+ const pinErrors = linuxContentPinErrors({ directory: nativeDir, manifest });
+ if (pinErrors.length > 0) {
+ throw new Error(`Packaged Linux native core failed content verification: ${pinErrors.join("; ")}`);
+ }
+ }
return { backendDir, manifest, nativeDir, platform };
}
diff --git a/desktop/src/main.cjs b/desktop/src/main.cjs
index 9f6983f2..61e0c99d 100644
--- a/desktop/src/main.cjs
+++ b/desktop/src/main.cjs
@@ -1759,15 +1759,17 @@ function checkForUpdatesOnStartup() {
}
function getTrayIconPath() {
- if (process.platform === "darwin") {
+ // Linux 的 nativeImage 解不了 .ico(那是 Windows 的容器),托盘只能吃 PNG,
+ // 否则 createTray 直接报 Failed to load image。macOS / Linux 共用同一张 icon.png。
+ if (process.platform === "darwin" || process.platform === "linux") {
const packaged = path.join(process.resourcesPath, "icon.png");
try {
if (app.isPackaged && fs.existsSync(packaged)) return packaged;
} catch {}
- const devMac = path.resolve(__dirname, "..", "src", "icon.png");
+ const devPng = path.resolve(__dirname, "..", "src", "icon.png");
try {
- if (fs.existsSync(devMac)) return devMac;
+ if (fs.existsSync(devPng)) return devPng;
} catch {}
}
diff --git a/desktop/src/native-core-runtime.cjs b/desktop/src/native-core-runtime.cjs
index b0f88179..e00b1551 100644
--- a/desktop/src/native-core-runtime.cjs
+++ b/desktop/src/native-core-runtime.cjs
@@ -15,6 +15,44 @@ const NATIVE_CORE_MODES = new Set(["required"]);
const NATIVE_CORE_SECURITY_NOTICE_ID = "WCE-AUTOMATED-ANALYSIS-NOTICE-V2";
const NATIVE_CORE_SECURITY_CHECKPOINT_SET_ID = "WCE-AI-CHECKPOINT-SET-V3";
const NATIVE_CORE_SECURITY_CHECKPOINT_COUNT = 7;
+const ZERO_SHA256_HEX = "0".repeat(64);
+const LINUX_MANIFEST_FIELDS = [
+ "linuxIntegrityMode",
+ "linuxClientSha256",
+ "linuxBrokerSha256",
+ "linuxPeerVerification",
+ "linuxHostVerification",
+];
+// Linux 清单是纯内容哈希身份:不得夹带任何代码签名身份字段,与 native_core_client
+// 的字段隔离约束一致(在那里由 NativeCoreProtocolError 拒绝)。
+const CODE_SIGNING_IDENTITY_FIELDS = [
+ "windowsSignerTrustMode",
+ "windowsPrivatePkiLeafRevocation",
+ "windowsClientSignerSha256",
+ "windowsBrokerSignerSha256",
+ "windowsPrivateRootSha256",
+ "windowsHostVerification",
+ "macosSigningMode",
+ "macosSignerTrustMode",
+ "macosPrivatePkiLeafRevocation",
+ "macosClientSigningIdentifier",
+ "macosBrokerSigningIdentifier",
+ "macosHostSigningIdentifier",
+ "macosClientSignerSha256",
+ "macosBrokerSignerSha256",
+ "macosHostSignerSha256",
+ "macosPrivateRootSha256",
+ "macosHostVerification",
+];
+// Linux 的发布形态(schema v4)没有代码签名:身份 = 两组内容哈希 pin + 直接父进程的
+// 宿主校验。发布工作流发的就是这一份受限 source-public 产物,所以冻结应用必须消费它
+// ——与 Windows(schema v2)同一原则。macOS(schema v3)走真正的签名 production,
+// 冻结态只认 production。
+const PACKAGED_SOURCE_PUBLIC_SCHEMAS = new Set([2, 4]);
+
+function hasOwnField(value, name) {
+ return Object.prototype.hasOwnProperty.call(value || {}, name);
+}
function isNonZeroSha256(value) {
const text = String(value || "");
@@ -28,6 +66,9 @@ function nativeCoreArtifactNames(platform = process.platform) {
if (platform === "darwin") {
return ["libwechatdb_client.dylib", "wechatdb_broker", NATIVE_CORE_MANIFEST];
}
+ if (platform === "linux") {
+ return ["libwechatdb_client.so", "wechatdb_broker", NATIVE_CORE_MANIFEST];
+ }
return [];
}
@@ -56,14 +97,53 @@ function hasCompleteNativeCore(nativeDir, platform = process.platform, fsImpl =
}
function hasValidManifestIdentity(manifest) {
+ const identityMatches =
+ (manifest?.schemaVersion === 2 && !hasOwnField(manifest, "platform")) ||
+ (manifest?.schemaVersion === 3 && manifest?.platform === "macos") ||
+ (manifest?.schemaVersion === 4 && manifest?.platform === "linux");
+ if (!identityMatches) return false;
+ if (typeof manifest.buildId !== "string" || !BUILD_ID_PATTERN.test(manifest.buildId)) {
+ return false;
+ }
+ // Linux 的内容哈希身份字段属于 Linux 清单专有:schema v2/v3 不得夹带,
+ // schema v4 必须完整声明且不得混入签名身份字段。与 native_core_client 的字段
+ // 隔离约束一致,避免出现「桌面放行、后端拒绝」的半可用状态。
+ const declaredLinuxFields = LINUX_MANIFEST_FIELDS.filter((name) =>
+ hasOwnField(manifest, name)
+ ).length;
+ if (manifest.schemaVersion === 4) {
+ return (
+ declaredLinuxFields === LINUX_MANIFEST_FIELDS.length &&
+ !CODE_SIGNING_IDENTITY_FIELDS.some((name) => hasOwnField(manifest, name))
+ );
+ }
+ return declaredLinuxFields === 0;
+}
+
+function hasLinuxContentHashIdentity(manifest) {
+ const clientPin = String(manifest?.linuxClientSha256 || "").toLowerCase();
+ const brokerPin = String(manifest?.linuxBrokerSha256 || "").toLowerCase();
return (
- ((manifest?.schemaVersion === 2 && !Object.prototype.hasOwnProperty.call(manifest, "platform")) ||
- (manifest?.schemaVersion === 3 && manifest?.platform === "macos")) &&
- typeof manifest?.buildId === "string" &&
- BUILD_ID_PATTERN.test(manifest.buildId)
+ manifest?.platform === "linux" &&
+ manifest.linuxIntegrityMode === "content-hash-pin" &&
+ manifest.linuxPeerVerification === "same-user-peer-credentials" &&
+ isNonZeroSha256(clientPin) &&
+ isNonZeroSha256(brokerPin) &&
+ clientPin !== brokerPin
);
}
+// 宿主校验强度必须与 sourceRuntime 自洽(与 native_core_client 的授权矩阵同一条规则):
+// 源码分发只认「直接父进程」,其余情况用「内容哈希 pin」;development 构建不带
+// sourceRuntime,所以这里只覆盖两种签发态。
+function hasLinuxHostVerificationPairing(manifest) {
+ const expected =
+ manifest?.sourceRuntime === true
+ ? "same-user-direct-parent"
+ : "content-hash-pin";
+ return manifest?.linuxHostVerification === expected;
+}
+
function hasActiveProductionBuildWindow(
manifest,
{ nowUnix = Math.floor(Date.now() / 1000) } = {}
@@ -156,6 +236,9 @@ function isProductionNativeCoreManifestBase(manifest, options = {}) {
new Set(pins.map((value) => String(value).toLowerCase())).size === 4
);
}
+ if (manifest.schemaVersion === 4) {
+ return hasLinuxContentHashIdentity(manifest) && hasLinuxHostVerificationPairing(manifest);
+ }
return (
isNonZeroSha256(manifest.windowsClientSignerSha256) &&
isNonZeroSha256(manifest.windowsBrokerSignerSha256) &&
@@ -195,6 +278,9 @@ function isSourcePublicNativeCoreManifest(manifest, options = {}) {
if (manifest.schemaVersion === 3) {
return manifest.macosHostVerification === "same-user-direct-parent";
}
+ if (manifest.schemaVersion === 4) {
+ return manifest.linuxHostVerification === "same-user-direct-parent";
+ }
return (
manifest.schemaVersion === 2 &&
manifest.windowsHostVerification === "same-user-direct-parent"
@@ -240,7 +326,21 @@ function isDevelopmentNativeCoreManifest(manifest) {
hasExpectedLeafRevocation(manifest) &&
identifiers.every((value) => /^[A-Za-z0-9.-]+$/.test(String(value || ""))) &&
new Set(identifiers).size === 3 &&
- pins.every((value) => String(value || "") === "0".repeat(64))
+ pins.every((value) => String(value || "") === ZERO_SHA256_HEX)
+ );
+ }
+ if (manifest.schemaVersion === 4) {
+ // dev-local 的 Linux 构建不携带任何内容哈希身份,且不声明 sourceRuntime。
+ return (
+ manifest.platform === "linux" &&
+ manifest.linuxIntegrityMode === "development" &&
+ manifest.linuxPeerVerification === "same-user-peer-credentials" &&
+ manifest.sourceRuntime !== true &&
+ new Set(["content-hash-pin", "same-user-direct-parent"]).has(
+ manifest.linuxHostVerification
+ ) &&
+ String(manifest.linuxClientSha256 || "") === ZERO_SHA256_HEX &&
+ String(manifest.linuxBrokerSha256 || "") === ZERO_SHA256_HEX
);
}
return manifest.windowsSignerTrustMode === "public" && hasExpectedLeafRevocation(manifest);
@@ -249,7 +349,8 @@ function isDevelopmentNativeCoreManifest(manifest) {
function manifestMatchesPlatform(manifest, platform) {
return (
(platform === "win32" && manifest?.schemaVersion === 2) ||
- (platform === "darwin" && manifest?.schemaVersion === 3 && manifest?.platform === "macos")
+ (platform === "darwin" && manifest?.schemaVersion === 3 && manifest?.platform === "macos") ||
+ (platform === "linux" && manifest?.schemaVersion === 4 && manifest?.platform === "linux")
);
}
@@ -272,7 +373,8 @@ function resolveNativeCoreRuntimePolicy({
const platformMatch = complete && manifestMatchesPlatform(manifest, platform);
const production = platformMatch && (
isProductionNativeCoreManifest(manifest, { nowUnix }) ||
- (isPackaged && manifest?.schemaVersion === 2 &&
+ (isPackaged &&
+ PACKAGED_SOURCE_PUBLIC_SCHEMAS.has(manifest?.schemaVersion) &&
isSourcePublicNativeCoreManifest(manifest, { nowUnix }))
);
const sourcePublic =
@@ -299,7 +401,15 @@ function resolveNativeCoreRuntimePolicy({
"Source WeChatDataAnalysis on macOS requires the exact restricted source-public wechatdb native core"
);
}
- if (!isPackaged && platform !== "darwin" && !sourcePublic && !development) {
+ // Linux 与 macOS 同一条规则:源码态只接受受限 source-public 产物(发布工作流发的就是
+ // 这一份)。后端 native_core_client 在 Linux 上同样只授权 source-public,两边必须一致,
+ // 否则出现「桌面放行、后端拒绝」的半可用状态。
+ if (!isPackaged && platform === "linux" && !sourcePublic) {
+ throw new Error(
+ "Source WeChatDataAnalysis on Linux requires the exact restricted source-public wechatdb native core"
+ );
+ }
+ if (!isPackaged && platform === "win32" && !sourcePublic && !development) {
throw new Error(
"Source WeChatDataAnalysis on Windows requires the exact restricted source-public or dev-local wechatdb native core"
);
diff --git a/desktop/tests/macos-xkey-signing.test.cjs b/desktop/tests/macos-xkey-signing.test.cjs
index c3220602..015212da 100644
--- a/desktop/tests/macos-xkey-signing.test.cjs
+++ b/desktop/tests/macos-xkey-signing.test.cjs
@@ -168,7 +168,8 @@ test("macOS private workflow verifies the pinned integrity Release before extrac
/"macos-integrity":\s*\(\s*"macos-integrity-production\.yml",\s*"wce-integrity-macos-arm64-production"/
);
assert.match(rebuildRelease, /tag = f"\{component\}-\{build_id\}"/);
- assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\.zip"/);
+ // 资产扩展名按组件区分:macOS/Windows 是 zip,Linux 是可复现 tar.gz。
+ assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\{suffix\}"/);
assert.match(rebuildRelease, /release = api\(f"releases\/tags\/\{tag\}"\)/);
assert.match(rebuildRelease, /release\.get\("target_commitish"\) != revision/);
assert.match(rebuildRelease, /releases\/assets\/\{asset\['id'\]\}/);
diff --git a/desktop/tests/native-core-packaging.test.cjs b/desktop/tests/native-core-packaging.test.cjs
index 082f64af..d7273dbf 100644
--- a/desktop/tests/native-core-packaging.test.cjs
+++ b/desktop/tests/native-core-packaging.test.cjs
@@ -1,5 +1,6 @@
const test = require("node:test");
const assert = require("node:assert/strict");
+const crypto = require("crypto");
const fs = require("fs");
const os = require("os");
const path = require("path");
@@ -19,6 +20,7 @@ const {
WINDOWS_NATIVE_ASR_TARGET,
} = require("../src/windows-native-asr-capability.cjs");
const { buildWindowsPeWithExports } = require("./pe-export-fixture.cjs");
+const { PRODUCER_WORKFLOW } = require("../scripts/linux-native-core-packaging.cjs");
const BUILD_ISSUED_AT_UNIX = Math.floor(Date.now() / 1000) - 60;
const BUILD_LIFETIME_SECONDS = 45 * 24 * 60 * 60;
@@ -138,6 +140,155 @@ function quietLogger() {
return { log() {}, warn() {} };
}
+// ---- Linux(schema v4)固定件 -------------------------------------------------
+// Linux 没有代码签名,产物身份 = 内容哈希,所以固定件必须把哈希算对,
+// 否则测的就不是「校验逻辑」而是「固定件写错了」。
+const LINUX_BUILD_ISSUED_AT_UNIX = Math.floor(Date.now() / 1000) - 60;
+const LINUX_REPOSITORY = "LifeArchiveProject/WCDB";
+const LINUX_SOURCE_REVISION = "a8f42de851a34365834e566bf587089af5df7c19";
+const LINUX_BUILD_ID = "linux-x64-release-2026.09.16";
+
+function sha256Hex(buffer) {
+ return crypto.createHash("sha256").update(buffer).digest("hex");
+}
+
+// 最小可用 ELF 头:测试只需要 64 位 / 小端 / x86-64 / 类型正确。
+function linuxElfBytes(type) {
+ const buffer = Buffer.alloc(64);
+ buffer.write("\x7fELF", 0, "latin1");
+ buffer[4] = 2;
+ buffer[5] = 1;
+ buffer.writeUInt16LE(type, 16);
+ buffer.writeUInt16LE(0x3e, 18);
+ return buffer;
+}
+
+function linuxManifest({ sourceRuntime = true, overrides = {} } = {}) {
+ return {
+ schemaVersion: 4,
+ platform: "linux",
+ distributionMode: "public",
+ buildId: LINUX_BUILD_ID,
+ buildIssuedAtUnix: LINUX_BUILD_ISSUED_AT_UNIX,
+ buildExpiresAtUnix: LINUX_BUILD_ISSUED_AT_UNIX + BUILD_LIFETIME_SECONDS,
+ developmentBuild: false,
+ offlineBootstrapFeatureBits: 3,
+ offlineExportSealFormat: "WES2",
+ codeSignatureEnforced: true,
+ rootPublicKeyCompiled: true,
+ testHooksEnabled: false,
+ stagingPinnedSignerTrust: false,
+ linuxIntegrityMode: "content-hash-pin",
+ linuxClientSha256: "",
+ linuxBrokerSha256: "",
+ linuxPeerVerification: "same-user-peer-credentials",
+ linuxHostVerification: sourceRuntime ? "same-user-direct-parent" : "content-hash-pin",
+ securityNoticeId: "WCE-AUTOMATED-ANALYSIS-NOTICE-V2",
+ securityNoticeSha256: "aa".repeat(32),
+ securityCheckpointSetId: "WCE-AI-CHECKPOINT-SET-V3",
+ securityCheckpointCount: 7,
+ securityCheckpointSetSha256: "bb".repeat(32),
+ ...(sourceRuntime ? { sourceRuntime: true } : {}),
+ ...overrides,
+ };
+}
+
+const LINUX_CHECKSUM_FILE_NAMES = [
+ "Test-LinuxNativeProductionArtifact.py",
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+];
+
+function writeLinuxArtifactSet(
+ root,
+ { sourceRuntime = true, manifestOverrides = {}, provenanceOverrides = {}, tamperClient = false } = {}
+) {
+ fs.mkdirSync(root, { recursive: true });
+ const clientName = "libwechatdb_client.so";
+ const brokerName = "wechatdb_broker";
+ const manifestName = "wechatdb_native_build.json";
+ const clientBytes = linuxElfBytes(3);
+ const brokerBytes = linuxElfBytes(2);
+
+ const manifest = linuxManifest({ sourceRuntime, overrides: manifestOverrides });
+ manifest.linuxClientSha256 = sha256Hex(clientBytes);
+ manifest.linuxBrokerSha256 = sha256Hex(brokerBytes);
+ Object.assign(manifest, manifestOverrides);
+
+ fs.writeFileSync(path.join(root, clientName), clientBytes);
+ fs.writeFileSync(path.join(root, brokerName), brokerBytes);
+ fs.writeFileSync(path.join(root, "Test-LinuxNativeProductionArtifact.py"), "# fixture\n");
+ fs.writeFileSync(path.join(root, manifestName), JSON.stringify(manifest, null, 2));
+
+ const checksums = LINUX_CHECKSUM_FILE_NAMES.map(
+ (name) => `${sha256Hex(fs.readFileSync(path.join(root, name)))} ${name}`
+ ).join("\n") + "\n";
+ fs.writeFileSync(path.join(root, "SHA256SUMS.txt"), checksums);
+
+ const provenance = {
+ schemaVersion: 1,
+ artifactName: "wechatdb-native-linux-x64-source-public",
+ producer: "manual",
+ workflow: "manual",
+ repository: LINUX_REPOSITORY,
+ runId: 0,
+ runAttempt: 0,
+ sourceRevision: LINUX_SOURCE_REVISION,
+ build: {
+ architecture: "x64",
+ distributionMode: manifest.distributionMode,
+ expiresAtUnix: manifest.buildExpiresAtUnix,
+ id: manifest.buildId,
+ integrityMode: manifest.linuxIntegrityMode,
+ issuedAtUnix: manifest.buildIssuedAtUnix,
+ linuxBrokerSha256: manifest.linuxBrokerSha256,
+ linuxClientSha256: manifest.linuxClientSha256,
+ offlineBootstrapFeatureBits: manifest.offlineBootstrapFeatureBits,
+ offlineExportSealFormat: manifest.offlineExportSealFormat,
+ platform: "linux",
+ readOnlyBuild: true,
+ securityCheckpointCount: manifest.securityCheckpointCount,
+ securityCheckpointSetId: manifest.securityCheckpointSetId,
+ securityCheckpointSetSha256: manifest.securityCheckpointSetSha256,
+ securityNoticeId: manifest.securityNoticeId,
+ securityNoticeSha256: manifest.securityNoticeSha256,
+ ...(sourceRuntime
+ ? { linuxHostVerification: manifest.linuxHostVerification, sourceRuntime: true }
+ : {}),
+ ...(provenanceOverrides.build || {}),
+ },
+ manifestSha256: sha256Hex(fs.readFileSync(path.join(root, manifestName))),
+ checksumsSha256: sha256Hex(fs.readFileSync(path.join(root, "SHA256SUMS.txt"))),
+ artifacts: LINUX_CHECKSUM_FILE_NAMES.map((name) => ({
+ path: name,
+ sha256: sha256Hex(fs.readFileSync(path.join(root, name))),
+ size: fs.statSync(path.join(root, name)).size,
+ })),
+ };
+ const { build: _ignoredBuild, ...provenanceTopLevel } = provenanceOverrides;
+ Object.assign(provenance, provenanceTopLevel);
+ fs.writeFileSync(path.join(root, "provenance.json"), JSON.stringify(provenance, null, 2));
+
+ if (tamperClient) {
+ // 密封之后再改字节:SHA256SUMS / provenance 仍然声称原始哈希。
+ const bytes = Buffer.from(fs.readFileSync(path.join(root, clientName)));
+ bytes[40] ^= 0xff;
+ fs.writeFileSync(path.join(root, clientName), bytes);
+ }
+ return { manifest, provenance };
+}
+
+function linuxEnv(artifactDir, overrides = {}) {
+ return {
+ WCE_NATIVE_CORE_ARTIFACT_DIR: artifactDir,
+ WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: LINUX_REPOSITORY,
+ WCE_NATIVE_CORE_SOURCE_REVISION: LINUX_SOURCE_REVISION,
+ WCE_NATIVE_CORE_BUILD_ID: LINUX_BUILD_ID,
+ ...overrides,
+ };
+}
+
test("artifact names are platform-specific and complete", () => {
assert.deepEqual(nativeCoreArtifactNames("win32"), [
"wechatdb_client.dll",
@@ -149,7 +300,11 @@ test("artifact names are platform-specific and complete", () => {
"wechatdb_broker",
"wechatdb_native_build.json",
]);
- assert.deepEqual(nativeCoreArtifactNames("linux"), []);
+ assert.deepEqual(nativeCoreArtifactNames("linux"), [
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+ ]);
});
test("runtime staging filters checked-out native and legacy WCDB files", () => {
@@ -599,20 +754,200 @@ test("malformed and structurally invalid manifests fail even with a development
);
assert.throws(
() => resolveNativeCoreArtifacts({ env, platform: "win32" }),
- /schemaVersion must equal 2 or 3; buildId must be a non-empty string/
+ /schemaVersion must equal 2, 3 or 4; buildId must be a non-empty string/
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
-test("unsupported platforms stay optional but fail closed when configured", () => {
- const optional = resolveNativeCoreArtifacts({ env: {}, platform: "linux" });
- assert.equal(optional.artifactDir, null);
+test("Linux native core is a required closed artifact set", () => {
assert.throws(
- () => resolveNativeCoreArtifacts({ env: { WCE_NATIVE_CORE_REQUIRED: "yes" }, platform: "linux" }),
- /unsupported on platform: linux/
+ () => resolveNativeCoreArtifacts({ env: {}, platform: "linux" }),
+ /Missing WCE_NATIVE_CORE_ARTIFACT_DIR/
);
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: { WCE_NATIVE_CORE_REQUIRED: "yes" },
+ platform: "linux",
+ }),
+ /Missing WCE_NATIVE_CORE_ARTIFACT_DIR/
+ );
+});
+
+test("Linux source-public and production profiles both resolve from sealed artifacts", () => {
+ const root = makeTempDir();
+ try {
+ for (const sourceRuntime of [true, false]) {
+ const artifactDir = path.join(root, sourceRuntime ? "sp" : "prod");
+ writeLinuxArtifactSet(artifactDir, { sourceRuntime });
+ const resolved = resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir),
+ platform: "linux",
+ });
+ assert.equal(resolved.required, true);
+ assert.equal(resolved.allowDevelopment, false);
+ assert.deepEqual(resolved.names, [
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+ ]);
+ assert.equal(
+ resolved.manifest.linuxHostVerification,
+ sourceRuntime ? "same-user-direct-parent" : "content-hash-pin"
+ );
+ }
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux content-hash pins reject any post-seal tampering", () => {
+ const root = makeTempDir();
+ try {
+ const artifactDir = path.join(root, "tampered");
+ writeLinuxArtifactSet(artifactDir, { tamperClient: true });
+ assert.throws(
+ () => resolveNativeCoreArtifacts({ env: linuxEnv(artifactDir), platform: "linux" }),
+ /checksum set does not match the artifact allowlist/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux protected pins fail closed on build id, revision and repository drift", () => {
+ const root = makeTempDir();
+ try {
+ const artifactDir = path.join(root, "pinned");
+ writeLinuxArtifactSet(artifactDir);
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_BUILD_ID: "linux-x64-other-2026.09.16" }),
+ platform: "linux",
+ }),
+ /does not match the protected build id pin/
+ );
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_SOURCE_REVISION: "0".repeat(40) }),
+ platform: "linux",
+ }),
+ /provenance revision does not match the protected pin/
+ );
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_ARTIFACT_REPOSITORY: "evil/fork" }),
+ platform: "linux",
+ }),
+ /provenance repository does not match the protected pin/
+ );
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(artifactDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "123" }),
+ platform: "linux",
+ }),
+ /must not claim a CI run/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux artifacts are only accepted from the reviewed producer workflow", () => {
+ const root = makeTempDir();
+ try {
+ // A workflow-produced artifact has to come from the reviewed producer, not
+ // from any workflow that happens to know the pin format.
+ const rogueDir = path.join(root, "rogue");
+ writeLinuxArtifactSet(rogueDir, {
+ provenanceOverrides: {
+ producer: "github-actions",
+ workflow: ".github/workflows/rogue-production.yml",
+ runId: 4242,
+ runAttempt: 1,
+ },
+ });
+ assert.throws(
+ () =>
+ resolveNativeCoreArtifacts({
+ env: linuxEnv(rogueDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "4242" }),
+ platform: "linux",
+ }),
+ /must come from \.github\/workflows\/linux-native-production\.yml/
+ );
+
+ const reviewedDir = path.join(root, "reviewed");
+ writeLinuxArtifactSet(reviewedDir, {
+ provenanceOverrides: {
+ producer: "github-actions",
+ workflow: PRODUCER_WORKFLOW,
+ runId: 4242,
+ runAttempt: 1,
+ },
+ });
+ const resolved = resolveNativeCoreArtifacts({
+ env: linuxEnv(reviewedDir, { WCE_NATIVE_CORE_ARTIFACT_RUN_ID: "4242" }),
+ platform: "linux",
+ });
+ assert.equal(resolved.provenance.runId, 4242);
+ assert.equal(resolved.provenance.workflow, PRODUCER_WORKFLOW);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("Linux profile self-consistency and binary identity are enforced", () => {
+ const root = makeTempDir();
+ try {
+ // 声明 sourceRuntime 却用 production 强度的 host 校验:必须拒绝。
+ const inconsistent = path.join(root, "inconsistent");
+ writeLinuxArtifactSet(inconsistent, {
+ manifestOverrides: { linuxHostVerification: "content-hash-pin" },
+ });
+ assert.throws(
+ () => resolveNativeCoreArtifacts({ env: linuxEnv(inconsistent), platform: "linux" }),
+ /linuxHostVerification must equal same-user-direct-parent/
+ );
+
+ // 客户端不是 ELF:必须拒绝。
+ const notElf = path.join(root, "not-elf");
+ writeLinuxArtifactSet(notElf);
+ fs.writeFileSync(path.join(notElf, "libwechatdb_client.so"), "not an elf at all");
+ assert.throws(
+ () => resolveNativeCoreArtifacts({ env: linuxEnv(notElf), platform: "linux" }),
+ /checksum set does not match the artifact allowlist/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
+
+test("packaged Linux native core is re-hashed before packing", () => {
+ const root = makeTempDir();
+ try {
+ const { validatePackagedBackend } = require("../scripts/native-core-before-pack.cjs");
+ const nativeDir = path.join(root, "native");
+ writeLinuxArtifactSet(nativeDir);
+ fs.writeFileSync(path.join(root, "wechat-backend"), "# packaged backend\n");
+
+ const validated = validatePackagedBackend({ backendDir: root, platform: "linux" });
+ assert.equal(validated.platform, "linux");
+
+ // 打包后再被替换一个字节 → 内容哈希必须拦住。
+ fs.writeFileSync(path.join(nativeDir, "wechatdb_broker"), linuxElfBytes(3));
+ assert.throws(
+ () => validatePackagedBackend({ backendDir: root, platform: "linux" }),
+ /Packaged Linux native core failed content verification: content hash mismatch for wechatdb_broker/
+ );
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
});
test("boolean packaging flags reject ambiguous values", () => {
diff --git a/desktop/tests/native-core-runtime.test.cjs b/desktop/tests/native-core-runtime.test.cjs
index 4d75f9c2..47dd2c76 100644
--- a/desktop/tests/native-core-runtime.test.cjs
+++ b/desktop/tests/native-core-runtime.test.cjs
@@ -8,6 +8,7 @@ const {
ENV_NATIVE_CORE_ALLOW_DEVELOPMENT_BUILD,
ENV_NATIVE_CORE_MODE,
applyNativeCoreRuntimePolicy,
+ isDevelopmentNativeCoreManifest,
isProductionNativeCoreManifest,
isSourcePublicNativeCoreManifest,
nativeCoreArtifactNames,
@@ -127,6 +128,57 @@ const MACOS_SOURCE_PUBLIC_MANIFEST = Object.freeze({
macosHostVerification: "same-user-direct-parent",
});
+// Linux(schema v4)没有代码签名:身份 = 两组内容哈希 pin + 宿主校验策略。
+const ZERO_SHA256 = "00".repeat(32);
+const LINUX_PRODUCTION_MANIFEST = Object.freeze({
+ schemaVersion: 4,
+ platform: "linux",
+ distributionMode: "public",
+ buildId: "linux-x64-20260915-abcd1234",
+ buildIssuedAtUnix: BUILD_ISSUED_AT_UNIX,
+ buildExpiresAtUnix: BUILD_ISSUED_AT_UNIX + BUILD_LIFETIME_SECONDS,
+ developmentBuild: false,
+ offlineBootstrapFeatureBits: 3,
+ offlineExportSealFormat: "WES2",
+ codeSignatureEnforced: true,
+ rootPublicKeyCompiled: true,
+ testHooksEnabled: false,
+ stagingPinnedSignerTrust: false,
+ linuxIntegrityMode: "content-hash-pin",
+ linuxClientSha256: "11".repeat(32),
+ linuxBrokerSha256: "22".repeat(32),
+ linuxPeerVerification: "same-user-peer-credentials",
+ linuxHostVerification: "content-hash-pin",
+ securityNoticeId: "WCE-AUTOMATED-ANALYSIS-NOTICE-V2",
+ securityNoticeSha256: "aa".repeat(32),
+ securityCheckpointSetId: "WCE-AI-CHECKPOINT-SET-V3",
+ securityCheckpointCount: 7,
+ securityCheckpointSetSha256: "bb".repeat(32),
+});
+
+// 发布工作流(linux-private-build.yml)只收这一份受限 source-public 产物。
+const LINUX_SOURCE_PUBLIC_MANIFEST = Object.freeze({
+ ...LINUX_PRODUCTION_MANIFEST,
+ sourceRuntime: true,
+ linuxHostVerification: "same-user-direct-parent",
+});
+
+const LINUX_DEVELOPMENT_MANIFEST = Object.freeze({
+ ...LINUX_PRODUCTION_MANIFEST,
+ buildId: "dev-local",
+ buildIssuedAtUnix: 0,
+ buildExpiresAtUnix: 0,
+ developmentBuild: true,
+ offlineBootstrapFeatureBits: 0,
+ offlineExportSealFormat: "none",
+ codeSignatureEnforced: false,
+ rootPublicKeyCompiled: false,
+ testHooksEnabled: true,
+ linuxIntegrityMode: "development",
+ linuxClientSha256: ZERO_SHA256,
+ linuxBrokerSha256: ZERO_SHA256,
+});
+
function makeArtifacts(platform, manifest, { omit = [] } = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-native-runtime-"));
for (const name of nativeCoreArtifactNames(platform)) {
@@ -423,6 +475,163 @@ test("Windows source-public artifacts are accepted for the read-only packaged ru
}
});
+test("Linux source-public artifacts are accepted for the packaged runtime", () => {
+ // 回归:Linux 的发布形态就是 source-public,冻结应用消费不了它就等于发不了版。
+ const sourceDir = makeArtifacts("linux", LINUX_SOURCE_PUBLIC_MANIFEST);
+ const productionDir = makeArtifacts("linux", LINUX_PRODUCTION_MANIFEST);
+ try {
+ assert.equal(isSourcePublicNativeCoreManifest(LINUX_SOURCE_PUBLIC_MANIFEST), true);
+ assert.equal(isProductionNativeCoreManifest(LINUX_SOURCE_PUBLIC_MANIFEST), false);
+ assert.equal(isProductionNativeCoreManifest(LINUX_PRODUCTION_MANIFEST), true);
+
+ const sourcePolicy = applyNativeCoreRuntimePolicy({}, {
+ isPackaged: false,
+ nativeDir: sourceDir,
+ platform: "linux",
+ });
+ assert.equal(sourcePolicy.artifactState, "source-public");
+ assert.equal(sourcePolicy.reason, "source-public-artifacts");
+ assert.equal(sourcePolicy.enableDevelopmentOverride, false);
+
+ const packagedSource = resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir: sourceDir,
+ platform: "linux",
+ });
+ assert.equal(packagedSource.artifactState, "production");
+ assert.equal(packagedSource.mode, "required");
+
+ const packagedProduction = resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir: productionDir,
+ platform: "linux",
+ });
+ assert.equal(packagedProduction.artifactState, "production");
+ } finally {
+ cleanup(sourceDir);
+ cleanup(productionDir);
+ }
+});
+
+test("source and packaged Linux artifact profiles cannot be swapped", () => {
+ const productionDir = makeArtifacts("linux", LINUX_PRODUCTION_MANIFEST);
+ const developmentDir = makeArtifacts("linux", LINUX_DEVELOPMENT_MANIFEST);
+ try {
+ // 源码态只接受受限 source-public(与 macOS 同一原则,也与 native_core_client 一致)。
+ assert.throws(
+ () => applyNativeCoreRuntimePolicy({}, {
+ isPackaged: false,
+ nativeDir: productionDir,
+ platform: "linux",
+ }),
+ /requires the exact restricted source-public/
+ );
+ assert.throws(
+ () => resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir: developmentDir,
+ platform: "linux",
+ }),
+ /requires an approved production/
+ );
+ assert.equal(isDevelopmentNativeCoreManifest(LINUX_DEVELOPMENT_MANIFEST), true);
+ } finally {
+ cleanup(productionDir);
+ cleanup(developmentDir);
+ }
+});
+
+test("Linux content-hash identity substitution fails closed", () => {
+ const rejected = [
+ { ...LINUX_PRODUCTION_MANIFEST, linuxClientSha256: ZERO_SHA256 },
+ { ...LINUX_PRODUCTION_MANIFEST, linuxBrokerSha256: ZERO_SHA256 },
+ {
+ ...LINUX_PRODUCTION_MANIFEST,
+ linuxBrokerSha256: LINUX_PRODUCTION_MANIFEST.linuxClientSha256,
+ },
+ { ...LINUX_PRODUCTION_MANIFEST, linuxIntegrityMode: "development" },
+ { ...LINUX_PRODUCTION_MANIFEST, linuxPeerVerification: "same-user-any-person" },
+ // 宿主校验强度必须与 sourceRuntime 配对。
+ { ...LINUX_SOURCE_PUBLIC_MANIFEST, sourceRuntime: false },
+ {
+ ...LINUX_SOURCE_PUBLIC_MANIFEST,
+ linuxHostVerification: "content-hash-pin",
+ },
+ { ...LINUX_PRODUCTION_MANIFEST, sourceRuntime: true },
+ // Linux 清单不得夹带代码签名身份字段(后端会直接拒绝)。
+ { ...LINUX_PRODUCTION_MANIFEST, windowsClientSignerSha256: "11".repeat(32) },
+ { ...LINUX_PRODUCTION_MANIFEST, macosSignerTrustMode: "private-pki" },
+ // v2/v3 不得夹带 Linux 内容哈希字段。
+ { ...PRODUCTION_MANIFEST, linuxClientSha256: "11".repeat(32) },
+ { ...MACOS_PRODUCTION_MANIFEST, linuxPeerVerification: "same-user-peer-credentials" },
+ ];
+ for (const manifest of rejected) {
+ assert.equal(isProductionNativeCoreManifest(manifest), false);
+ assert.equal(isSourcePublicNativeCoreManifest(manifest), false);
+ const nativeDir = makeArtifacts("linux", manifest);
+ try {
+ assert.throws(
+ () => resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir,
+ platform: "linux",
+ }),
+ /requires an approved production/
+ );
+ } finally {
+ cleanup(nativeDir);
+ }
+ }
+});
+
+test("Linux manifest schemas cannot cross platform boundaries", () => {
+ const linuxWithWindowsManifest = makeArtifacts("linux", PRODUCTION_MANIFEST);
+ const windowsWithLinuxManifest = makeArtifacts("win32", LINUX_SOURCE_PUBLIC_MANIFEST);
+ const macWithLinuxManifest = makeArtifacts("darwin", LINUX_SOURCE_PUBLIC_MANIFEST);
+ try {
+ assert.throws(
+ () => resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir: linuxWithWindowsManifest,
+ platform: "linux",
+ }),
+ /requires an approved production/
+ );
+ assert.throws(
+ () => resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir: windowsWithLinuxManifest,
+ platform: "win32",
+ }),
+ /requires an approved production/
+ );
+ assert.throws(
+ () => resolveNativeCoreRuntimePolicy({
+ env: {},
+ isPackaged: true,
+ nativeDir: macWithLinuxManifest,
+ platform: "darwin",
+ }),
+ /requires an approved production/
+ );
+ assert.deepEqual(nativeCoreArtifactNames("linux"), [
+ "libwechatdb_client.so",
+ "wechatdb_broker",
+ "wechatdb_native_build.json",
+ ]);
+ } finally {
+ cleanup(linuxWithWindowsManifest);
+ cleanup(windowsWithLinuxManifest);
+ cleanup(macWithLinuxManifest);
+ }
+});
+
test("every production manifest gate fails closed", () => {
const missingStagingTrust = { ...PRODUCTION_MANIFEST };
delete missingStagingTrust.stagingPinnedSignerTrust;
diff --git a/desktop/tests/package-config.test.cjs b/desktop/tests/package-config.test.cjs
index 43eed0f8..c319fd4c 100644
--- a/desktop/tests/package-config.test.cjs
+++ b/desktop/tests/package-config.test.cjs
@@ -10,6 +10,41 @@ const desktopRoot = path.resolve(__dirname, "..");
const repoRoot = path.resolve(desktopRoot, "..");
const packageJson = JSON.parse(fs.readFileSync(path.join(desktopRoot, "package.json"), "utf8"));
+// Every remote action a release workflow may reference, pinned to an approved
+// commit. Both the tag-triggered release workflow and the platform build
+// workflows it calls are checked against this single list.
+const APPROVED_ACTIONS = new Map([
+ ["actions/checkout", "11d5960a326750d5838078e36cf38b85af677262"],
+ ["actions/setup-node", "49933ea5288caeca8642d1e84afbd3f7d6820020"],
+ ["actions/setup-python", "a26af69be951a213d495a4c3e4e4022e16d87065"],
+ ["actions/cache", "0057852bfaa89a56745cba8c7296529d2fc39830"],
+ ["actions/download-artifact", "d3f86a106a0bac45b974a628896c90dbdf5c8093"],
+ ["actions/upload-artifact", "ea165f8d65b6e75b540449e92b4886f43607fa02"],
+ ["dtolnay/rust-toolchain", "4cda84d5c5c54efe2404f9d843567869ab1699d4"],
+ ["softprops/action-gh-release", "3bb12739c298aeb8a4eeaf626c5b8d85266b0e65"],
+]);
+
+function assertRemoteActionsPinned(workflow) {
+ const remoteUses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)]
+ .map((match) => match[1])
+ .filter((use) => !use.startsWith("./"));
+ assert.ok(remoteUses.length > 0);
+ for (const use of remoteUses) {
+ const separator = use.lastIndexOf("@");
+ const action = use.slice(0, separator);
+ const revision = use.slice(separator + 1);
+ assert.match(revision, /^[0-9a-f]{40}$/, `${use} is not pinned to a commit`);
+ assert.equal(revision, APPROVED_ACTIONS.get(action), `${action} uses an unapproved commit`);
+ }
+ return remoteUses;
+}
+
+function readWorkflow(name) {
+ return fs
+ .readFileSync(path.join(repoRoot, ".github", "workflows", name), "utf8")
+ .replace(/\r\n/g, "\n");
+}
+
test("desktop package excludes the retired Koffi and WCDB sidecar runtime", () => {
const nodeModulesRule = packageJson.build.files.find(
(item) => item && typeof item === "object" && item.from === "node_modules"
@@ -189,9 +224,15 @@ test("Windows release uses protected cloud private-PKI signing and installer smo
/"windows-native":\s*\(\s*"windows-native-production\.yml",\s*"wechatdb-native-windows-x64-source-public"/
);
assert.match(rebuildRelease, /tag = f"\{component\}-\{build_id\}"/);
- assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\.zip"/);
+ assert.match(rebuildRelease, /asset_name = f"\{artifact_name\}-\{build_id\}\{suffix\}"/);
assert.match(rebuildRelease, /release\.get\("target_commitish"\) != revision/);
assert.match(rebuildRelease, /expected_digest = asset\.get\("digest"\)/);
+ // Linux 走同一条自动重建路线,只是资产换成可复现的 tar.gz。
+ assert.match(
+ rebuildRelease,
+ /"linux-native":\s*\(\s*"linux-native-production\.yml",\s*"wechatdb-native-linux-x64-source-public"/
+ );
+ assert.match(rebuildRelease, /WCE_NATIVE_CORE_CLIENT_SHA256|f"\{prefix\}_CLIENT_SHA256"/);
assert.match(windowsJob, /WCE_WINDOWS_PRIVATE_ROOT_CERT_PATH/);
assert.match(windowsJob, /WCE_WINDOWS_PRIVATE_ROOT_SHA256/);
assert.match(windowsJob, /WCE_RFC3161_TIMESTAMP_URL/);
@@ -295,32 +336,7 @@ test("Windows release uses protected cloud private-PKI signing and installer smo
});
test("release workflow pins every remote action to an approved commit", () => {
- const workflow = fs
- .readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8")
- .replace(/\r\n/g, "\n");
- const approved = new Map([
- ["actions/checkout", "11d5960a326750d5838078e36cf38b85af677262"],
- ["actions/setup-node", "49933ea5288caeca8642d1e84afbd3f7d6820020"],
- ["actions/setup-python", "a26af69be951a213d495a4c3e4e4022e16d87065"],
- ["actions/cache", "0057852bfaa89a56745cba8c7296529d2fc39830"],
- ["actions/download-artifact", "d3f86a106a0bac45b974a628896c90dbdf5c8093"],
- ["actions/upload-artifact", "ea165f8d65b6e75b540449e92b4886f43607fa02"],
- ["dtolnay/rust-toolchain", "4cda84d5c5c54efe2404f9d843567869ab1699d4"],
- ["softprops/action-gh-release", "3bb12739c298aeb8a4eeaf626c5b8d85266b0e65"],
- ["H3CoF6/qq-notify-action", "50d180981e7c7b8552a3331b981e3f8cfcf40c44"],
- ]);
- const remoteUses = [...workflow.matchAll(/^\s*uses:\s*([^\s#]+)(?:\s+#.*)?$/gm)]
- .map((match) => match[1])
- .filter((use) => !use.startsWith("./"));
-
- assert.ok(remoteUses.length > 0);
- for (const use of remoteUses) {
- const separator = use.lastIndexOf("@");
- const action = use.slice(0, separator);
- const revision = use.slice(separator + 1);
- assert.match(revision, /^[0-9a-f]{40}$/, `${use} is not pinned to a commit`);
- assert.equal(revision, approved.get(action), `${action} uses an unapproved commit`);
- }
+ const remoteUses = assertRemoteActionsPinned(readWorkflow("release.yml"));
for (const action of [
"actions/checkout",
"actions/setup-node",
@@ -329,7 +345,107 @@ test("release workflow pins every remote action to an approved commit", () => {
"actions/upload-artifact",
"softprops/action-gh-release",
]) {
- assert.ok(remoteUses.includes(`${action}@${approved.get(action)}`), `${action} is missing`);
+ assert.ok(
+ remoteUses.includes(`${action}@${APPROVED_ACTIONS.get(action)}`),
+ `${action} is missing`
+ );
+ }
+});
+
+test("the tag release requires and publishes the Linux x64 package", () => {
+ const workflow = readWorkflow("release.yml");
+ const releaseJob = workflow.match(
+ /\n build-linux-x64:\n([\s\S]*?)(?=\n [A-Za-z0-9_-]+:\n|$)/
+ )?.[1] || "";
+ assert.match(releaseJob, /uses:\s*\.\/\.github\/workflows\/linux-private-build\.yml/);
+ assert.match(releaseJob, /secrets:\s*inherit/);
+
+ const publishJob = workflow.match(
+ /\n publish-release:\n([\s\S]*?)(?=\n [A-Za-z0-9_-]+:\n|$)/
+ )?.[1] || "";
+ assert.match(publishJob, /- build-windows/);
+ assert.match(publishJob, /- build-macos-arm64/);
+ // Linux is a required platform: a missing native-core pin fails the release
+ // instead of silently publishing without it.
+ assert.match(publishJob, /- build-linux-x64/);
+});
+
+test("Linux release workflow rebuilds the native core and publishes the unrooted payload", () => {
+ const workflow = readWorkflow("linux-private-build.yml");
+ const job = workflow.match(
+ /\n build-linux-x64:\n([\s\S]*?)$/
+ )?.[1] || "";
+ assert.ok(job, "build-linux-x64 job is missing");
+ assert.match(workflow, /workflow_call:/);
+ assert.match(workflow, /workflow_dispatch:/);
+ assert.match(job, /runs-on:\s*ubuntu-22\.04/);
+ assert.match(job, /if:\s*github\.ref == 'refs\/heads\/main' \|\| startsWith\(github\.ref, 'refs\/tags\/v'\)/);
+
+ // Linux 与 Windows / macOS 同一条自动重建路线:发版当下现产 source-public 原生核心,
+ // 所以消费工作流里不许再出现任何仓库变量 pin,也不再需要额外的读取 secret——
+ // 只用发版已有的 WCE_NATIVE_CORE_PRODUCER_TOKEN。
+ assert.doesNotMatch(job, /\$\{\{\s*vars\.WCE_LINUX_/);
+ assert.doesNotMatch(job, /WCE_LINUX_PRODUCER_READ_TOKEN/);
+ assert.match(job, /python3 tools\/rebuild_wcdb_release\.py/);
+ assert.match(job, /--component linux-native/);
+ assert.match(job, /secrets\.WCE_NATIVE_CORE_PRODUCER_TOKEN/);
+ assert.doesNotMatch(job, /WCE_INTEGRITY_ARTIFACT_DIR/);
+
+ const order = [
+ "Verify immutable source and release coordinates",
+ "Rebuild the Linux native core for this release",
+ "Validate the pinned native core against the production policy",
+ "Checkout the private integrity source at the producer revision",
+ "Build the Linux package",
+ "Verify the packaged Linux runtime",
+ "Prepare Linux release checksums and provenance",
+ "Upload Linux release files",
+ ];
+ let previous = -1;
+ for (const step of order) {
+ const index = job.indexOf(step);
+ assert.ok(index >= 0, `${step} is missing`);
+ assert.ok(index > previous, `${step} is out of order`);
+ previous = index;
+ }
+
+ // 重建脚本自己核对不可变 Release 资产摘要、revision 与 45 天窗口,
+ // 工作流不再有 Download / 回退到 Actions artifact 的分支。
+ assert.doesNotMatch(job, /gh release download/);
+ assert.doesNotMatch(job, /gh run download/);
+ assert.match(job, /resolveLinuxNativeCoreArtifacts\(\{ platform: 'linux' \}\)/);
+ // integrity 源码按当次重建出来的 producer revision 取,不再依赖仓库变量。
+ assert.match(
+ job,
+ /repos\/\$WCE_NATIVE_CORE_ARTIFACT_REPOSITORY\/tarball\/\$WCE_NATIVE_CORE_SOURCE_REVISION/
+ );
+ assert.match(job, /native\/wce_integrity\/Cargo\.toml/);
+ assert.doesNotMatch(job, /cargo build/);
+ assert.match(job, /tests\/test_linux_db_key_flow\.py/);
+ assert.match(job, /tests\/test_linux_native_core_policy\.py/);
+ assert.doesNotMatch(job, /test_wcdb_realtime_native_core_required\.py/);
+ assert.doesNotMatch(job, /test_native_core_broker_lifecycle\.py/);
+ // 桌面门禁必须覆盖「启动后端」那一步的策略判定:曾经它只认 win32/darwin,
+ // 于是 Linux 包能出包、一启动就崩。
+ assert.match(job, /tests\/native-core-runtime\.test\.cjs/);
+ assert.match(job, /resolveNativeCoreRuntimePolicy/);
+ assert.match(job, /WECHAT_TOOL_NATIVE_CORE_MODE/);
+ assert.match(job, /npm run dist:linux/);
+ assert.match(job, /differs from the reviewed native artifact/);
+ assert.match(job, /linuxContentPinErrors/);
+ assert.match(job, /SHA256SUMS-linux\.txt/);
+ assert.match(job, /release-provenance-linux\.json/);
+ assert.match(job, /desktop\/dist\/\*-linux-x86_64\.tar\.gz/);
+ assert.match(job, /name:\s*release-linux-x64/);
+});
+
+test("the Linux release workflow pins every remote action to an approved commit", () => {
+ const remoteUses = assertRemoteActionsPinned(readWorkflow("linux-private-build.yml"));
+ for (const action of ["actions/checkout", "actions/upload-artifact"]) {
+ assert.ok(
+ remoteUses.includes(`${action}@${APPROVED_ACTIONS.get(action)}`),
+ `${action} is missing`
+ );
}
});
@@ -643,13 +759,13 @@ test("macOS DMG cleanup preserves both detach failures", () => {
);
});
-test("tag release reuses the protected macOS build and publishes both platforms", () => {
+test("tag release reuses the protected platform builds and publishes every platform", () => {
const workflow = fs
.readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8")
.replace(/\r\n/g, "\n");
const publishJob = workflow.split("\n publish-release:\n", 2)[1] || "";
- assert.match(workflow, /^name: Release \(Windows and macOS ARM64\)$/m);
+ assert.match(workflow, /^name: Release \(Windows, macOS ARM64 and Linux x64\)$/m);
assert.match(
workflow,
/\n build-macos-arm64:\n\s+uses: \.\/\.github\/workflows\/macos-private-build\.yml\n\s+secrets: inherit/
@@ -704,3 +820,40 @@ test("frontend joins copied output paths using the native path style", async ()
assert.equal(joinNativePath("D:\\wechat\\output\\", "wxid_demo"), "D:\\wechat\\output\\wxid_demo");
assert.equal(joinNativePath("\\\\server\\share\\output", "wxid_demo"), "\\\\server\\share\\output\\wxid_demo");
});
+
+test("Linux ships as an unpacked directory plus a checksum-verified install script", async () => {
+ // 刻意不做 AppImage / deb:Linux 的形态是 dist/linux-unpacked + install.sh。
+ assert.deepEqual(packageJson.build.linux.target, ["dir"]);
+ assert.equal(packageJson.build.linux.executableName, "wechat-data-analysis");
+ assert.equal(packageJson.build.linux.icon, "src/icon.png");
+ assert.match(packageJson.scripts["dist:linux"], /electron-builder --linux dir --x64/);
+ assert.match(packageJson.scripts["dist:linux"], /build-linux-installer\.cjs/);
+
+ const os = require("os");
+ const { spawnSync } = require("child_process");
+ const { buildLinuxInstaller } = require("../scripts/build-linux-installer.cjs");
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "wda-linux-installer-"));
+ try {
+ const payloadDir = path.join(root, "linux-unpacked");
+ fs.mkdirSync(path.join(payloadDir, "resources"), { recursive: true });
+ fs.writeFileSync(path.join(payloadDir, "wechat-data-analysis"), "#!/bin/sh\nexit 0\n");
+ fs.chmodSync(path.join(payloadDir, "wechat-data-analysis"), 0o755);
+
+ const result = buildLinuxInstaller({ payloadDir, outputDir: path.join(root, "dist") });
+ assert.ok(fs.existsSync(result.archivePath));
+ assert.ok(fs.existsSync(result.installerPath));
+ assert.equal(result.sha256, crypto.createHash("sha256").update(fs.readFileSync(result.archivePath)).digest("hex"));
+
+ const installer = fs.readFileSync(result.installerPath, "utf8");
+ assert.equal(installer.includes("@@"), false, "installer must not keep template placeholders");
+ assert.match(installer, new RegExp(result.sha256));
+ assert.match(installer, /PAYLOAD_SHA256=/);
+ assert.match(installer, /--uninstall/);
+ assert.match(installer, /wechat-data-analysis\.desktop/);
+
+ const syntax = spawnSync("sh", ["-n", result.installerPath], { encoding: "utf8" });
+ assert.equal(syntax.status, 0, syntax.stderr);
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/docs/linux-release.md b/docs/linux-release.md
new file mode 100644
index 00000000..0d78ab87
--- /dev/null
+++ b/docs/linux-release.md
@@ -0,0 +1,101 @@
+# Linux 发布流程(x64)
+
+Linux 与 Windows / macOS 一起发在同一个 tag Release 里,并且是**必需平台**:原生组件重建
+失败时,`release.yml` 会直接失败,而不是静默少发一个平台。
+
+Linux 与 Windows / macOS 走**同一条**原生组件路线:发版当下现产一份 source-public 原生核心,
+所以不需要任何仓库变量 pin,也不需要额外 secret(只复用发版已有的
+`WCE_NATIVE_CORE_PRODUCER_TOKEN`)。45 天有效期由「每次发版重建」自然续上。
+
+产物形态刻意不做 AppImage / deb:Linux 走「用户级、免 root 的 `tar.gz` + `install.sh`」。
+
+## 涉及的三个工作流
+
+| 工作流 | 位置 | 作用 |
+| --- | --- | --- |
+| `linux-native-production.yml` | **WCDB**(私藏 producer 仓) | 被 `rebuild_wcdb_release.py` dispatch:构建 + 自检 + 把原生核心发成不可变 Release 资产 |
+| `tools/rebuild_wcdb_release.py` | 本仓 | 发版当下 dispatch producer,等它跑完,按 Release 摘要下载并核对 45 天窗口 |
+| `linux-private-build.yml` | 本仓 | 可复用构建:重建原生核心 → 校验 → 编译 integrity → `dist:linux` → 打包校验 → 上传 |
+| `release.yml` | 本仓 | `push tag v*` 触发;`build-linux-x64` 调用上面的可复用工作流,`publish-release` 汇总三个平台 |
+
+## 操作顺序
+
+1. **配 producer**(WCDB 私藏仓,一次性):
+
+ - 仓库级 variable `WCE_ROOT_PUBLIC_KEY_HEX`:128 hex,P-256 **公钥**(不是私钥),
+ 与 macOS / Windows environment 里那把一致。
+ - 其余什么都不用配:Linux 没有代码签名,不需要任何私钥 / 证书 / 时间戳 secret
+ (Windows 的 PFX、macOS 的 P12 在 Linux 上都不存在)。
+
+2. **发版**:推 tag `v*`。tag 必须在 `origin/main` 上。
+
+ 发版里 `build-linux-x64` 会自动 `tools/rebuild_wcdb_release.py --component linux-native`:
+ dispatch WCDB 的 `Linux native production`,等它产出一份带唯一 build id 的不可变 Release,
+ 核对 Release target / 资产摘要 / 45 天窗口后才继续打包。
+
+3. **本仓需要的唯一配置**:仓库级 secret `WCE_NATIVE_CORE_PRODUCER_TOKEN`
+ (对 `2977094657/WCDB` 有 Actions read/write 与 Contents read)。Windows / macOS 发版
+ 已经在用同一个 secret,Linux 直接复用,**不需要新增任何配置**。
+
+## 为什么可以不做代码签名
+
+Linux 没有 Authenticode / codesign 的等价物,所以身份改成**内容哈希**,方向是单向的:
+
+- broker 里编死了「随包 client 的哈希」;
+- broker 自己的哈希由 manifest 声明、由安装方 pin。
+
+`sha256(file) == pin(file)` 无解,所以「组件自带自身哈希」这种不可判定的方向被显式禁止:
+`Test-LinuxNativeProductionArtifact.py` 与 `desktop/scripts/linux-native-core-packaging.cjs`
+两头都断言了这一点。消费侧还会在打包后再哈希一次(`packaged ... differs from the reviewed
+native artifact`),确保打包过程没有顺手重编。
+
+导出完整性模块 `libwce_integrity.so` 由本仓在发布时用**一次性构建密钥**现编(语义等同于
+官方的 `-GenerateEphemeralSigningKey`):该密钥只用于导出物自身封签,权威封印是原生核心产出的
+WES2 sidecar。之所以不在 producer 侧预编,是因为 `wce_integrity` 会把 Nuxt 的 CSS 编进去,
+必须和当次 UI 构建同源。
+
+## 会踩的坑
+
+- **45 天有效期**:manifest 固定 45 天窗口。因为每次发版都重建,安装包自带的组件始终是
+ 当次构建;旧安装包到期后需要装新版本(与 Windows / macOS 同一行为)。
+- **构建 ID 不可复用**:producer 在发布前会检查 `linux-native-` 是否已存在,存在即拒绝。
+- **校验失败就是失败**:`build-linux-x64` 不设 `continue-on-error`,`publish-release.needs` 包含它,
+ 所以重建失败 / 摘要不符 / 哈希漂移都会让 release 停在半路而不是发出去。
+- **重跑要用新的 run attempt**:build id 由 WCDA 的 run id + attempt 派生,同一次发版重跑
+ 会拿到新 id,不会撞上已发布的 tag。
+- **产物名不能重**:Linux 用 `SHA256SUMS-linux.txt` / `release-provenance-linux.json`,
+ 避免与 Windows 的 `SHA256SUMS.txt` / `release-provenance.json` 在 `merge-multiple` 下载时互相覆盖。
+- **producer 不占用 Actions artifact 配额**:Linux producer 只发不可变 Release 资产,
+ 没有 `upload-artifact` 步骤,所以 artifact 配额爆掉不会影响发版。
+
+## 桌面运行时的 Linux 判定(已修,别再回退)
+
+`desktop/src/native-core-runtime.cjs` 现在完整支持 Linux 的 schema v4,规则和
+Windows / macOS 对齐:
+
+| 运行形态 | 接受的产物 | 说明 |
+| --- | --- | --- |
+| 打包(冻结) | production 或受限 source-public | 与 Windows 同一原则:发布工作流发的就是 source-public |
+| 源码 checkout | 只接受受限 source-public | 与 macOS 同一原则(`dev-local` 不授权) |
+
+Linux 没有代码签名,身份 = `linuxClientSha256` / `linuxBrokerSha256` 两组内容哈希
+pin;`linuxHostVerification` 必须与 `sourceRuntime` 配对(源码分发 = 直接父进程,
+其余 = 内容哈希 pin),且 Linux 清单不得夹带任何 Windows / macOS 的签名身份字段。
+这四条在**两侧**都要成立,缺一就会出现「桌面放行、后端拒绝」的半可用状态:
+
+- 桌面:`desktop/src/native-core-runtime.cjs` + `desktop/tests/native-core-runtime.test.cjs`
+- 后端:`src/wechat_decrypt_tool/native_core_client.py` + `tests/test_linux_native_core_policy.py`
+
+两条都被 `build-linux-x64` 当门禁跑,所以「能出包」和「能用」之间不再有缝。
+
+## 还没做的验证
+
+- **没有 GUI 冒烟**:Windows 有 `smoke:win`、macOS 有 `smoke:mac`,Linux 侧只有
+ 「打包产物上的原生核心策略判定」(`Verify the packaged Linux runtime` 步骤)加
+ `install.sh` 的摘要校验,没有真的启动过界面。
+- **Ubuntu 24.04 的沙箱限制**:用户级安装没法给 `chrome-sandbox` 置 setuid root,
+ 而 24.04 起 AppArmor 会限制非特权 user namespace —— 真机验证时若起不来,优先查
+ 这一条(需要 AppArmor profile 或 `--no-sandbox` 的取舍)。
+- **`dev-local` 在 Linux 上不授权**:本地自建开发核心(`WCE_DEVELOPMENT_BUILD=ON`
+ 产出的 `linuxIntegrityMode: development` 清单)不会被后端接受,本地联调需要用
+ producer 产的 source-public 产物(与 macOS 现状一致)。
diff --git a/docs/release-native-build.md b/docs/release-native-build.md
index 8a6830ec..5b4f4b2f 100644
--- a/docs/release-native-build.md
+++ b/docs/release-native-build.md
@@ -1,8 +1,8 @@
# Release native builds
-Windows and macOS packaging rebuild their WCDB components from the current
-`2977094657/WCDB` main revision. Each producer receives a unique build ID and
-the current UTC time. The signed components and their manifests expire exactly
+Windows, macOS and Linux packaging rebuild their WCDB components from the
+current `2977094657/WCDB` main revision. Each producer receives a unique build
+ID and the current UTC time. The components and their manifests expire exactly
45 days after that time. A failed producer stops packaging.
`tools/rebuild_wcdb_release.py` downloads the exact Release asset for each
@@ -14,7 +14,9 @@ the existing signature and provenance checks. It does not use Actions artifact
storage or an older Release as a fallback.
The macOS native core, key helper and export-integrity module are built in
parallel. The integrity module uses the exact WeChatDataAnalysis revision being
-packaged.
+packaged. The Linux native core is the source-public profile: it is hash
+enforced rather than signed, so its producer needs no signing identities and
+its Release asset is a reproducible `tar.gz` instead of a `zip`.
## GitHub configuration
@@ -24,6 +26,7 @@ Deploy these production workflows to the main branch of `2977094657/WCDB`:
- `macos-native-production.yml`
- `macos-key-capture-production.yml`
- `macos-integrity-production.yml`
+- `linux-native-production.yml`
Configure their protected environments with the existing signing identities:
`windows-native-production`, `macos-native-production` and
@@ -36,6 +39,10 @@ In `LifeArchiveProject/WeChatDataAnalysis`, add repository secret
`2977094657/WCDB`, Actions read/write and Contents read. Store it directly in
GitHub Actions secrets; do not place it in source files or build arguments.
+`linux-native-production.yml` does not use an environment (it has no signing
+material to protect); it reads the repository-level `WCE_ROOT_PUBLIC_KEY_HEX`
+that is also compiled into the Windows and macOS components.
+
Keep the trusted signing pins and host signing secrets in
`windows-private-pki-production` and `macos-private-pki-production`.
The macOS environment requires the native client, broker, host and root pins,
diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts
index f8826f94..54c2571d 100644
--- a/frontend/nuxt.config.ts
+++ b/frontend/nuxt.config.ts
@@ -59,6 +59,7 @@ export default defineNuxtConfig({
// 「高级功能」弹窗复用官网的 pro-demos 演示引擎(website/assets 下),跨根导入需要别名,
// 并让 dev server 额外放行 website/assets(保留 Vite 默认推断的工作区根,不把整个仓库暴露给 /@fs/)
vite: {
+ ssr: { noExternal: ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue'] },
plugins: [tailwindcss()],
resolve: {
alias: [{ find: '@website', replacement: websiteAssetsDir }]
diff --git a/frontend/pages/decrypt.vue b/frontend/pages/decrypt.vue
index 5d799ab3..f9028f5b 100644
--- a/frontend/pages/decrypt.vue
+++ b/frontend/pages/decrypt.vue
@@ -82,9 +82,11 @@
{{ isMacos
? '优先调用本地受控组件;仅在明确失败且您再次确认后,才提供实验性本机调试兜底。获取接口仅允许本机访问。'
+ : isLinux
+ ? '点击按钮将由 wx_key 拉起微信并在弹出的窗口中完成登录以获取【数据库解密密钥】;Linux 不执行内存扫描。您也可以手动输入已知的64位密钥。'
: '点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。您也可以手动输入已知的64位密钥。' }}
-
+
@@ -119,7 +121,7 @@
id="dbPath"
v-model="formData.db_storage_path"
type="text"
- :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'"
+ :placeholder="isMacos ? '例如: /Users/你的用户名/.../<账号目录>/db_storage(账号目录可能是 wxid_... 或自定义名称)' : isLinux ? '例如: /home/你的用户名/Documents/xwechat_files/wxid_xxx/db_storage' : '例如: D:\\wechatMSG\\xwechat_files\\wxid_xxx\\db_storage'"
class="w-full px-4 py-3 bg-white border border-[#EDEDED] rounded-lg font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[#07C160] focus:border-transparent transition-all duration-200"
:class="{ 'border-red-500': formErrors.db_storage_path }"
required
@@ -1140,6 +1142,9 @@ const macosKeyCaptureCleanupInFlight = ref(false)
const platformCapabilities = ref({ platform: '' })
const platformCapabilitiesLoaded = ref(false)
const isMacos = computed(() => platformCapabilities.value?.platform === 'macos')
+const isLinux = computed(() => platformCapabilities.value?.platform === 'linux')
+// 路径分隔符:只有 Windows 用反斜杠,Linux 与 macOS 一样是正斜杠。
+const pathSeparator = computed(() => (platformCapabilities.value?.platform === 'windows' ? '\\' : '/'))
const imageKeyMemoryScanChecking = computed(() => !platformCapabilitiesLoaded.value)
const imageKeyMemoryScanSupported = computed(() => {
if (!platformCapabilitiesLoaded.value) return false
@@ -2262,20 +2267,40 @@ const handleGetDbKey = async () => {
return
}
- const shouldContinue = await requestGuideDialog({
- eyebrow: '密钥获取提示',
- title: '获取前请确认微信已登录',
- description: '系统会先尝试从当前运行的微信中扫描数据库密钥。这里只做操作提醒,不会强制检查登录状态。',
- details: [
- '保持电脑版微信运行,并登录需要解密的账号',
- '确认下方数据库路径属于同一个微信账号',
- '获取期间不要退出微信或切换到其他账号'
- ],
- note: '如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。',
- primaryLabel: '准备好了,开始获取',
- secondaryLabel: '暂不获取',
- tone: 'guide'
- })
+ // Linux 没有 V4 内存扫描这一套逻辑(见 platform_support / key_service 的说明):
+ // wx_key 采用 fork + TRACEME 自己拉起微信,一次到位,不存在「先扫内存、失败再改用 Hook」。
+ // 因此这里不能走 Windows 的提示与兜底流程,否则会先误导用户「正在扫描内存」,
+ // 再弹一次永远不可能成功的「内存扫描失败,是否改用 Hook?」。
+ const shouldContinue = await requestGuideDialog(isLinux.value
+ ? {
+ eyebrow: '密钥获取提示',
+ title: '获取前请确认微信已登录',
+ description: '获取密钥时会由 wx_key 拉起微信,请在它弹出的微信窗口里完成登录;Linux 不执行内存扫描。',
+ details: [
+ '获取时会先关闭正在运行的微信,再由 wx_key 重新拉起',
+ '请关闭微信的「自动登录」,在弹出的窗口里手动登录同一个账号',
+ '程序不能以 root 运行,否则 AppImage 版微信没有窗口',
+ '获取期间不要退出微信或切换到其他账号'
+ ],
+ note: '这里只做操作提醒,不会强制检查登录状态。',
+ primaryLabel: '准备好了,开始获取',
+ secondaryLabel: '暂不获取',
+ tone: 'guide'
+ }
+ : {
+ eyebrow: '密钥获取提示',
+ title: '获取前请确认微信已登录',
+ description: '系统会先尝试从当前运行的微信中扫描数据库密钥。这里只做操作提醒,不会强制检查登录状态。',
+ details: [
+ '保持电脑版微信运行,并登录需要解密的账号',
+ '确认下方数据库路径属于同一个微信账号',
+ '获取期间不要退出微信或切换到其他账号'
+ ],
+ note: '如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。',
+ primaryLabel: '准备好了,开始获取',
+ secondaryLabel: '暂不获取',
+ tone: 'guide'
+ })
if (!shouldContinue) return
const requestRevision = ++dbKeyRequestRevision
@@ -2330,7 +2355,11 @@ const handleGetDbKey = async () => {
}
let res = null
- if (dbStoragePath) {
+ if (isLinux.value) {
+ // Linux 直接走 Hook:没有内存扫描可尝试,后端也会拒绝 key_v4 模式。
+ res = await fetchByHook()
+ if (!isDbKeyRequestActive(requestRevision, requestController)) return
+ } else if (dbStoragePath) {
warning.value = '正在优先尝试 V4 内存扫描获取数据库密钥。'
res = await getKeys({
wechat_install_path: wechatInstallPath,
@@ -3610,8 +3639,9 @@ onMounted(async () => {
platformCapabilities.value = await getPlatformCapabilities()
} catch {
const macos = /Macintosh|Mac OS X/i.test(String(navigator.userAgent || ''))
+ const linux = !macos && /Linux/i.test(String(navigator.userAgent || ''))
platformCapabilities.value = {
- platform: macos ? 'macos' : 'windows',
+ platform: macos ? 'macos' : linux ? 'linux' : 'windows',
database_key_extraction: !macos,
database_key_guidance: macos
? '未能确认 macOS 数据库密钥组件,请检查本地服务或更新完整应用。'
@@ -3619,6 +3649,8 @@ onMounted(async () => {
image_key_memory_scan: !macos,
image_key_memory_scan_note: macos
? '未能确认 macOS 图片密钥扫描资源,请检查本地服务后重试。'
+ : linux
+ ? '未能确认 Linux 平台的 wx_key 组件,请检查本地服务后重试。'
: ''
}
} finally {
@@ -3633,7 +3665,7 @@ onMounted(async () => {
const account = JSON.parse(selectedAccount)
// 填充数据路径
if (account.data_dir) {
- const separator = isMacos.value ? '/' : '\\'
+ const separator = pathSeparator.value
formData.db_storage_path = String(account.data_dir).replace(/[\\/]+$/, '') + separator + 'db_storage'
}
if (account.account_name) {
diff --git a/frontend/tests/assistant-ui-ssr-external.test.js b/frontend/tests/assistant-ui-ssr-external.test.js
new file mode 100644
index 00000000..dfe15bdd
--- /dev/null
+++ b/frontend/tests/assistant-ui-ssr-external.test.js
@@ -0,0 +1,43 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+import { describe, expect, it } from 'vitest'
+
+// 这个契约靠注释守不住,必须由测试守:
+// @assistant-ui/tap 的 react-shim 会 `import ... from "react"`,而 react 只是它的可选 peer,
+// 前端用 lib/assistant-ui-aliases.js 把 react 指到 standalone-shim。一旦这些包在 SSR 里被判为
+// external,就交给 Node 原生加载,Node 解析不到 react,/chat/[username] 直接 500。
+//
+// 实测坑:写成正则(/^@assistant-ui\//)会静默失效 —— Nuxt 把用户提供的 RegExp 序列化成字符串,
+// 于是它变成字面量 glob、永远匹配不上;同一条配置里 Nuxt 自带的条目仍然是 RegExp。所以这里
+// 必须钉住「包名字符串」这种写法。
+const configSource = readFileSync(resolve(process.cwd(), 'nuxt.config.ts'), 'utf8')
+
+function readNoExternalEntries() {
+ const match = configSource.match(/ssr:\s*\{\s*noExternal:\s*\[([^\]]*)\]/)
+ expect(match, 'nuxt.config.ts 里必须有 vite.ssr.noExternal').not.toBeNull()
+ return match[1]
+ .split(',')
+ .map((entry) => entry.trim())
+ .filter(Boolean)
+}
+
+describe('assistant-ui 的 SSR 内联契约', () => {
+ it('noExternal 覆盖四个 assistant-ui 包,且写成包名字符串', () => {
+ const entries = readNoExternalEntries()
+ for (const name of ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue']) {
+ expect(entries, `${name} 必须出现在 noExternal 里`).toContain(`'${name}'`)
+ }
+ })
+
+ it('noExternal 不允许出现正则字面量(RegExp 会被序列化成字符串而静默失效)', () => {
+ const entries = readNoExternalEntries()
+ const regexLike = entries.filter((entry) => entry.startsWith('/') || entry.startsWith('('))
+ expect(regexLike, `noExternal 里不能写正则: ${regexLike.join(', ')}`).toEqual([])
+ })
+
+ it('react 仍然通过别名指向 standalone-shim(配合 noExternal 一起生效)', () => {
+ const aliases = readFileSync(resolve(process.cwd(), 'lib/assistant-ui-aliases.js'), 'utf8')
+ expect(aliases).toContain("^react$")
+ expect(aliases).toContain('@assistant-ui/tap/standalone-shim')
+ })
+})
diff --git a/frontend/vitest.config.js b/frontend/vitest.config.js
index 8b19499c..12e8b3a0 100644
--- a/frontend/vitest.config.js
+++ b/frontend/vitest.config.js
@@ -12,6 +12,11 @@ export default defineConfig({
},
test: {
environment: 'happy-dom',
- include: ['tests/**/*.test.js']
+ include: ['tests/**/*.test.js'],
+ server: {
+ deps: {
+ inline: ['@assistant-ui/core', '@assistant-ui/store', '@assistant-ui/tap', '@assistant-ui/vue']
+ }
+ }
}
})
diff --git a/pyproject.toml b/pyproject.toml
index 10225c57..92dd760b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -20,7 +20,7 @@ dependencies = [
"pilk>=0.2.4",
"pypinyin>=0.53.0",
"jieba>=0.42.1",
- "wx_key>=2.0.1; sys_platform == 'win32'",
+ "wx_key>=2.1.1; sys_platform == 'win32' or sys_platform == 'linux'",
"pefile>=2024.8.26; sys_platform == 'win32'",
"pymem>=1.14.0; sys_platform == 'win32'",
"yara-python>=4.5.2; sys_platform == 'win32'",
diff --git a/src/wechat_decrypt_tool/key_service.py b/src/wechat_decrypt_tool/key_service.py
index 872cd578..ff77602d 100644
--- a/src/wechat_decrypt_tool/key_service.py
+++ b/src/wechat_decrypt_tool/key_service.py
@@ -1,7 +1,7 @@
# import sys
# import requests
-from .platform_support import is_macos, is_windows
+from .platform_support import is_linux, is_macos, is_windows
try:
import wx_key
@@ -49,6 +49,19 @@
logger = logging.getLogger(__name__)
WECHAT_EXECUTABLE_NAMES = ("Weixin.exe", "WeChat.exe")
+# Linux 版微信的可执行文件名:发行版包一般是 /usr/bin/wechat(符号链接到
+# /opt/wechat/wechat),本地安装则可能在 ~/.local/bin,AppImage 用户是 *.AppImage。
+LINUX_WECHAT_EXECUTABLE_NAMES = ("wechat", "wechat-bin")
+LINUX_WECHAT_EXECUTABLE_PATHS = (
+ "/usr/bin/wechat",
+ "/opt/wechat/wechat",
+ "/usr/local/bin/wechat",
+ "~/.local/bin/wechat",
+)
+
+
+def _wechat_executable_names() -> tuple[str, ...]:
+ return LINUX_WECHAT_EXECUTABLE_NAMES if is_linux() else WECHAT_EXECUTABLE_NAMES
KEY_SIZE = 32
V4_DB_NAME_PRIORITY = (
"msg0.db",
@@ -170,6 +183,10 @@ def _read_wechat_version_from_exe(exe_path: str) -> str:
normalized = _normalize_user_path(exe_path)
if not normalized:
return ""
+ if is_linux():
+ # Linux 侧没有 PE 版本资源可读,版本号不是必需信息(只用于展示/日志),
+ # 因此这里不编造,调用方按"未知版本"处理。
+ return ""
try:
import win32api
@@ -189,6 +206,27 @@ def _resolve_manual_wechat_exe_path(wechat_install_path: Optional[str] = None) -
if not normalized:
return ""
+ if is_linux():
+ # Linux 上"安装目录"这个概念很弱(发行版包 / AppImage / 解包目录都行),
+ # 因此只要求:是个可执行文件,或者目录里能找到标准的 wechat 可执行文件。
+ candidate = Path(normalized).expanduser()
+ if candidate.is_file():
+ if not os.access(candidate, os.X_OK):
+ raise RuntimeError(f"手动指定的微信文件不可执行: {candidate}")
+ return str(candidate)
+ if candidate.is_dir():
+ for exe_name in LINUX_WECHAT_EXECUTABLE_NAMES:
+ exe_path = candidate / exe_name
+ if exe_path.is_file():
+ return str(exe_path)
+ for exe_path in sorted(candidate.glob("*.AppImage")):
+ if exe_path.is_file():
+ return str(exe_path)
+ raise RuntimeError(
+ f"手动指定的目录中没有可用的微信可执行文件: {candidate}"
+ )
+ raise RuntimeError(f"手动指定的微信路径不存在: {candidate}")
+
candidate = Path(normalized).expanduser()
executable_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
if candidate.is_file():
@@ -546,7 +584,7 @@ def _try_recover(candidate_internal_db_key: bytes, source: str) -> str:
class WeChatKeyFetcher:
def __init__(self):
- self.process_names = {name.lower() for name in WECHAT_EXECUTABLE_NAMES}
+ self.process_names = {name.lower() for name in _wechat_executable_names()}
self.timeout_seconds = 60
def _is_wechat_process(self, name: Any) -> bool:
@@ -629,12 +667,33 @@ def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict:
logger.info(f"Detect WeChat: {version or 'unknown'} at {exe_path}")
- self.kill_wechat()
- pid = self.launch_wechat(exe_path)
- logger.info(f"WeChat launched, PID: {pid}")
+ if is_linux():
+ # Linux 的 wx_key ABI 与 Windows 不同:第一个参数是**微信可执行文件路径**,
+ # 由 wx_key 自己 fork + PTRACE_TRACEME 拉起微信,我们绝不能先自己 launch。
+ #
+ # 为什么能免提权:TRACEME 场景下 ptrace 的规则是"父进程追踪自己的子进程",
+ # Yama/ptrace_scope=1 也放行;而 attach 一个已在运行的微信则会被拦下、必须
+ # 提权(这也正是 Linux 不提供 v4 内存扫描的原因)。
+ #
+ # 提权边界必须干净:本进程若以 root 运行,被拉起的 AppImage 会因为
+ # **FUSE 对 root 不可见**而挂载失败(表现是"微信没有窗口"),所以在提权
+ # 发生之前就拒绝,而不是让用户面对一个静默失败的微信。
+ if os.geteuid() == 0:
+ raise RuntimeError(
+ "请以普通用户身份运行本程序后再获取密钥:root 环境无法挂载 "
+ "AppImage 版微信(FUSE 对 root 不可见),会表现为微信没有窗口。"
+ )
+ self.kill_wechat()
+ logger.info("[db_key] Linux hook:交给 wx_key 拉起微信: %s", exe_path)
+ armed = wx_key.initialize_hook(exe_path)
+ else:
+ self.kill_wechat()
+ pid = self.launch_wechat(exe_path)
+ logger.info(f"WeChat launched, PID: {pid}")
+ # 仅传入 PID,触发数据库密钥自动 Hook
+ armed = wx_key.initialize_hook(pid)
- # 仅传入 PID,触发数据库密钥自动 Hook
- if not wx_key.initialize_hook(pid):
+ if not armed:
err = wx_key.get_last_error_msg()
raise RuntimeError(f"数据库 Hook 初始化失败: {err}")
@@ -647,9 +706,21 @@ def fetch_db_key(self, wechat_install_path: Optional[str] = None) -> dict:
raise TimeoutError("获取数据库密钥超时 (60s),请确保在弹出的微信中完成登录。")
key_data = wx_key.poll_key_data()
- if key_data and 'key' in key_data:
- found_db_key = key_data['key']
- break
+ # 注意:wx_key 布防成功后可能先返回"带空 key 的占位结构"(Linux 上
+ # 实测如此),因此必须要求 key 非空;用 `'key' in key_data` 会把空值
+ # 当成结果立刻返回。py_wx_key 自己的 Linux 自测同样是判非空。
+ candidate_key = ""
+ if isinstance(key_data, dict):
+ candidate_key = str(key_data.get("key") or "").strip()
+ if candidate_key:
+ if not re.fullmatch(r"[0-9a-fA-F]{64}", candidate_key):
+ logger.warning(
+ "[db_key] hook 返回了非 64-hex 的候选密钥(len=%s),继续等待",
+ len(candidate_key),
+ )
+ else:
+ found_db_key = candidate_key
+ break
while True:
msg, level = wx_key.get_status_message()
@@ -717,6 +788,24 @@ def get_db_key_workflow(
dict(validation.get("modes") or {}),
)
return result
+ if is_linux():
+ # Linux 只提供 Hook 模式:wx_key 的 fork + TRACEME 免提权路径。
+ # Windows 那套 v4 内存扫描需要 attach 已运行的微信进程,在 Linux 上会被
+ # Yama/ptrace_scope 拦下、必须提权,且稳定性不如 fork 路径,因此不提供
+ # (与 py_wx_key 的 Linux 能力保持一致)。
+ mode = str(key_mode or "auto").strip().lower()
+ if mode in {"v4", "key_v4", "memory", "memory_scan"}:
+ raise RuntimeError(
+ "Linux 暂不支持 V4 内存扫描获取密钥(需要提权 attach 微信进程),"
+ "请使用 hook 模式。"
+ )
+ if mode not in {"auto", "hook"}:
+ raise RuntimeError(f"未知密钥获取模式: {key_mode}")
+ fetcher = WeChatKeyFetcher()
+ result = fetcher.fetch_db_key(wechat_install_path=wechat_install_path)
+ result["method"] = "hook"
+ return result
+
if not is_windows():
raise RuntimeError("当前平台不支持自动获取数据库密钥,请使用同类工具获取后手动填写。")
@@ -892,6 +981,29 @@ def _get_image_key_kvcomm_dirs(account_dir: Optional[Path] = None) -> tuple[Path
if cursor.parent == cursor:
break
cursor = cursor.parent
+ elif is_linux():
+ # Linux 版微信的 kvcomm 在 ~/.xwechat/net/kvcomm;换网络/重启后会留下
+ # net_1 / net_2 / net_3 … 历史目录,它们同样可能有可用的 code,因此都作为
+ # 候选(当前 net/ 优先,其余按 mtime 从新到旧)。
+ xwechat_root = Path.home() / ".xwechat"
+ candidates = [xwechat_root / "net" / "kvcomm"]
+ try:
+ historical = sorted(
+ (item for item in xwechat_root.glob("net_*") if item.is_dir()),
+ key=lambda item: item.stat().st_mtime_ns,
+ reverse=True,
+ )
+ except OSError:
+ historical = []
+ candidates.extend(item / "kvcomm" for item in historical)
+
+ if account_dir is not None:
+ cursor = Path(account_dir).expanduser()
+ for _ in range(6):
+ candidates.append(cursor / "net" / "kvcomm")
+ if cursor.parent == cursor:
+ break
+ cursor = cursor.parent
else:
appdata = str(os.environ.get("APPDATA") or "").strip()
appdata_root = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
diff --git a/src/wechat_decrypt_tool/native_core_broker.py b/src/wechat_decrypt_tool/native_core_broker.py
index 1a60ca3b..f0e030eb 100644
--- a/src/wechat_decrypt_tool/native_core_broker.py
+++ b/src/wechat_decrypt_tool/native_core_broker.py
@@ -10,6 +10,7 @@
import time
from pathlib import Path
+from .app_paths import get_data_dir
from .native_core_client import (
ENV_NATIVE_CORE_ENDPOINT,
ENV_NATIVE_CORE_LIBRARY,
@@ -117,13 +118,19 @@ def __exit__(self, _exc_type, _exc, _traceback) -> None:
def _broker_name() -> str:
if sys.platform.startswith("win"):
return "wechatdb_broker.exe"
- if sys.platform == "darwin":
+ if sys.platform == "darwin" or sys.platform.startswith("linux"):
return "wechatdb_broker"
- raise NativeCoreComponentMissingError("wechatdb native broker supports Windows and macOS only.")
+ raise NativeCoreComponentMissingError(
+ "wechatdb native broker supports Windows, macOS and Linux only."
+ )
def _client_name() -> str:
- return "wechatdb_client.dll" if sys.platform.startswith("win") else "libwechatdb_client.dylib"
+ if sys.platform.startswith("win"):
+ return "wechatdb_client.dll"
+ if sys.platform.startswith("linux"):
+ return "libwechatdb_client.so"
+ return "libwechatdb_client.dylib"
def _candidate_broker_paths() -> tuple[Path, ...]:
@@ -143,6 +150,8 @@ def _candidate_broker_paths() -> tuple[Path, ...]:
repo_root.parent / "wechatdb-native" / "build" / "windows-vs" / "Debug" / name,
repo_root.parent / "wechatdb-native" / "build" / "windows-msvc-debug" / name,
repo_root.parent / "wechatdb-native" / "build" / "macos-arm64-debug" / name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-debug" / name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-release" / name,
)
)
result: list[Path] = []
@@ -169,10 +178,61 @@ def _new_endpoint() -> str:
token = secrets.token_hex(12)
if sys.platform.startswith("win"):
return rf"\\.\pipe\LifeArchiveProject.WeChatDB.Native.{os.getpid()}.{token}"
- directory = tempfile.gettempdir().rstrip("/\\")
+ directory = os.fspath(_endpoint_directory()).rstrip("/\\")
return f"{directory}/lap-wce-{os.getpid()}-{token}.sock"
+def _endpoint_directory() -> Path:
+ """broker 会校验 socket 所在目录必须"本人拥有 + 组/他人不可写"。
+
+ macOS 的 TMPDIR 天生是 per-user 0700 目录,所以历史上直接用 gettempdir();
+ Linux 的 /tmp 是 sticky + world-writable(任何人都能占位这个 socket 名),
+ 原生侧会直接判 tamper 拒服务,因此优先用 $XDG_RUNTIME_DIR
+ (systemd 登录会话的 /run/user/,0700),缺失时退回一个自建 0700 目录。
+ """
+ if sys.platform == "darwin" or sys.platform.startswith("win"):
+ return Path(tempfile.gettempdir())
+ candidates: list[Path] = []
+ runtime_dir = str(os.environ.get("XDG_RUNTIME_DIR", "") or "").strip()
+ if runtime_dir:
+ candidates.append(Path(runtime_dir))
+ candidates.append(Path(get_data_dir()) / "native-core-run")
+ euid = os.geteuid()
+ for candidate in candidates:
+ # sun_path 只有 108 字节,给 socket 文件名(lap-wce--.sock)留余量。
+ if len(os.fspath(candidate)) > 60:
+ continue
+ try:
+ candidate.mkdir(parents=True, exist_ok=True)
+ os.chmod(candidate, 0o700)
+ status = candidate.stat()
+ except OSError:
+ continue
+ if status.st_uid != euid or (status.st_mode & 0o022) != 0:
+ continue
+ if (status.st_mode & 0o300) != 0o300:
+ continue
+ return candidate
+ raise NativeCoreUnavailableError(
+ "Cannot locate a private directory for the native core broker socket."
+ )
+
+
+def _unlink_unix_socket(endpoint: str) -> None:
+ """清理 unix socket 与它的 flock 锁文件(broker 用 .lock 互斥)。
+
+ 只应在确认 broker 进程已退出后调用(否则会破坏原生侧的单实例互斥)。
+ Windows 用的是命名管道,没有文件系统路径要删。
+ """
+ if not endpoint or sys.platform.startswith("win"):
+ return
+ for suffix in ("", ".lock"):
+ try:
+ Path(endpoint + suffix).unlink(missing_ok=True)
+ except OSError:
+ continue
+
+
def _startup_timeout_seconds() -> float:
default_timeout_ms = (
"60000" if sys.platform == "darwin" or sys.platform.startswith("win") else "5000"
@@ -521,8 +581,9 @@ def ensure_native_core_broker(
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
- if sys.platform == "darwin":
- Path(endpoint).unlink(missing_ok=True)
+ # broker 用的是 unix socket(macOS/Linux),失败路径也要清掉这个 socket 文件,
+ # 否则下次启动会撞上残留路径。Windows 是命名管道,没有文件要清。
+ _unlink_unix_socket(endpoint)
if isinstance(exc, NativeCoreUnavailableError) and log_path is not None:
tail = _broker_log_tail(log_path, log_start_offset)
detail = f" Broker log: {log_path}."
@@ -594,8 +655,7 @@ def stop_native_core_broker(*, _force: bool = False) -> None:
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
- if endpoint and sys.platform == "darwin":
- Path(endpoint).unlink(missing_ok=True)
+ _unlink_unix_socket(endpoint)
atexit.register(stop_native_core_broker, _force=True)
diff --git a/src/wechat_decrypt_tool/native_core_client.py b/src/wechat_decrypt_tool/native_core_client.py
index 1c3234b8..ac908a79 100644
--- a/src/wechat_decrypt_tool/native_core_client.py
+++ b/src/wechat_decrypt_tool/native_core_client.py
@@ -545,11 +545,19 @@ class NativeCoreBuildManifest:
source_runtime: bool = False
windows_host_verification: str = ""
macos_host_verification: str = ""
+ linux_client_sha256: bytes = field(default=b"\0" * 32, repr=False)
+ linux_broker_sha256: bytes = field(default=b"\0" * 32, repr=False)
+ linux_integrity_mode: str = ""
+ linux_peer_verification: str = ""
+ linux_host_verification: str = ""
@property
def client_signer_sha256(self) -> bytes:
if self.platform == "macos":
return self.macos_client_signer_sha256
+ if self.platform == "linux":
+ # Linux 没有签名者,身份即 client 的内容哈希 pin。
+ return self.linux_client_sha256
return self.windows_client_signer_sha256
@@ -951,7 +959,13 @@ def _load_native_core_build_manifest(
root_public_key_compiled = payload.get("rootPublicKeyCompiled")
test_hooks_enabled = payload.get("testHooksEnabled")
staging_pinned_signer_trust = payload.get("stagingPinnedSignerTrust")
- manifest_platform = "macos" if schema_version == 3 else "windows"
+ # schema 2 = Windows(历史形态,不带 platform 字段)、3 = macOS、4 = Linux。
+ if schema_version == 4:
+ manifest_platform = "linux"
+ elif schema_version == 3:
+ manifest_platform = "macos"
+ else:
+ manifest_platform = "windows"
windows_client_signer_sha256 = payload.get("windowsClientSignerSha256")
offline_bootstrap_feature_bits_value = payload.get(
"offlineBootstrapFeatureBits"
@@ -965,7 +979,7 @@ def _load_native_core_build_manifest(
offline_export_seal_format = payload.get("offlineExportSealFormat")
distribution_mode_value = payload.get("distributionMode")
distribution_capsule_value = payload.get("distributionCapsule")
- if type(schema_version) is not int or schema_version not in {2, 3}:
+ if type(schema_version) is not int or schema_version not in {2, 3, 4}:
raise NativeCoreProtocolError(
"wechatdb native build manifest has an unsupported schemaVersion."
)
@@ -973,6 +987,10 @@ def _load_native_core_build_manifest(
raise NativeCoreProtocolError(
"wechatdb native schemaVersion 3 requires platform macos."
)
+ if schema_version == 4 and payload.get("platform") != "linux":
+ raise NativeCoreProtocolError(
+ "wechatdb native schemaVersion 4 requires platform linux."
+ )
if schema_version == 2 and "platform" in payload:
raise NativeCoreProtocolError(
"wechatdb native schemaVersion 2 must not declare a platform."
@@ -985,7 +1003,9 @@ def _load_native_core_build_manifest(
raise NativeCoreProtocolError(
"Windows wechatdb native build manifest must declare readOnlyBuild=true and no WeChat actions."
)
- if schema_version == 3:
+ # macOS(v3) 与 Linux(v4) 的 manifest 不带 readOnlyBuild 字段:两者恒为只读 runtime,
+ # 不能让它留成 None(None 会让后面的判定链静默短路成 None)。
+ if schema_version in {3, 4}:
read_only_build = True
source_runtime = False
windows_host_verification = ""
@@ -1038,6 +1058,136 @@ def _load_native_core_build_manifest(
)
source_runtime = True
macos_host_verification = "same-user-direct-parent"
+ linux_client_sha256 = bytes(32)
+ linux_broker_sha256 = bytes(32)
+ linux_integrity_mode = ""
+ linux_peer_verification = ""
+ linux_host_verification = ""
+ if schema_version == 4:
+ # Linux 没有代码签名 / 签名者证书,身份由两组内容哈希 pin 承担:
+ # broker 钉 client 的文件 SHA-256(单向,build 脚本两遍构建保证可解),
+ # 进程间再靠 SO_PEERCRED + 直接父进程关系互认。
+ foreign_fields = (
+ "windowsClientSignerSha256",
+ "windowsBrokerSignerSha256",
+ "windowsPrivateRootSha256",
+ "windowsSignerTrustMode",
+ "windowsPrivatePkiLeafRevocation",
+ "windowsHostVerification",
+ "macosClientSignerSha256",
+ "macosBrokerSignerSha256",
+ "macosHostSignerSha256",
+ "macosPrivateRootSha256",
+ "macosClientSigningIdentifier",
+ "macosBrokerSigningIdentifier",
+ "macosHostSigningIdentifier",
+ "macosSigningMode",
+ "macosSignerTrustMode",
+ "macosPrivatePkiLeafRevocation",
+ "macosHostVerification",
+ )
+ declared = sorted(name for name in foreign_fields if name in payload)
+ if declared:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must not declare Windows or macOS "
+ "signing fields: " + ", ".join(declared)
+ )
+ if "linuxHostVerification" not in payload:
+ raise NativeCoreProtocolError(
+ "Linux native manifests must declare linuxHostVerification."
+ )
+ if "sourceRuntime" in payload and payload.get("sourceRuntime") is not True:
+ raise NativeCoreProtocolError(
+ "Linux source-runtime manifests must declare sourceRuntime=true."
+ )
+ linux_integrity_mode = payload.get("linuxIntegrityMode")
+ if linux_integrity_mode not in {"content-hash-pin", "development"}:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must declare linuxIntegrityMode "
+ "content-hash-pin or development."
+ )
+ if development_build != (linux_integrity_mode == "development"):
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must pair the development integrity "
+ "mode with developmentBuild."
+ )
+ linux_pin_values = (
+ payload.get("linuxClientSha256"),
+ payload.get("linuxBrokerSha256"),
+ )
+ if any(
+ not isinstance(value, str)
+ or re.fullmatch(r"[0-9a-f]{64}", value) is None
+ for value in linux_pin_values
+ ):
+ raise NativeCoreProtocolError(
+ "wechatdb native build manifest contains invalid Linux content-hash pins."
+ )
+ linux_client_sha256, linux_broker_sha256 = (
+ bytes.fromhex(value) for value in linux_pin_values
+ )
+ if development_build and (
+ any(linux_client_sha256) or any(linux_broker_sha256)
+ ):
+ raise NativeCoreProtocolError(
+ "Development Linux native builds must not carry production content-hash pins."
+ )
+ if not development_build and not (
+ any(linux_client_sha256) and any(linux_broker_sha256)
+ ):
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native content-hash pins must be non-zero."
+ )
+ # 两侧 pin 兼做进程互认的参照物,撞哈希就失去了区分能力:producer 的
+ # Test-LinuxNativeProductionArtifact.py 与消费侧的 linux-native-core-packaging.cjs
+ # 都断言了这一点,这里必须同样拒绝。
+ if not development_build and linux_client_sha256 == linux_broker_sha256:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native content-hash pins must be distinct."
+ )
+ linux_peer_verification = payload.get("linuxPeerVerification")
+ if linux_peer_verification != "same-user-peer-credentials":
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must declare the same-user peer "
+ "credential policy."
+ )
+ linux_host_verification = payload.get("linuxHostVerification")
+ if linux_host_verification not in {
+ "content-hash-pin",
+ "same-user-direct-parent",
+ }:
+ raise NativeCoreProtocolError(
+ "Linux wechatdb native manifests must declare a supported host "
+ "verification policy."
+ )
+ # 三份 profile:development(developmentBuild,无 sourceRuntime)、
+ # production(无 sourceRuntime,宿主校验=内容哈希)、
+ # source-public(sourceRuntime=true,宿主校验=直接父进程)。
+ source_runtime = bool(payload.get("sourceRuntime"))
+ if not development_build and (
+ linux_host_verification == "same-user-direct-parent"
+ ) != source_runtime:
+ raise NativeCoreProtocolError(
+ "Linux host verification must be paired with sourceRuntime outside "
+ "development builds."
+ )
+ if source_runtime and linux_integrity_mode != "content-hash-pin":
+ raise NativeCoreProtocolError(
+ "Linux source-runtime manifests must keep the production integrity mode."
+ )
+ if schema_version in {2, 3} and any(
+ name in payload
+ for name in (
+ "linuxIntegrityMode",
+ "linuxClientSha256",
+ "linuxBrokerSha256",
+ "linuxPeerVerification",
+ "linuxHostVerification",
+ )
+ ):
+ raise NativeCoreProtocolError(
+ "Only Linux wechatdb native manifests may declare Linux integrity fields."
+ )
if (
not isinstance(build_id, str)
or not _NATIVE_CORE_BUILD_ID_PATTERN.fullmatch(build_id)
@@ -1091,6 +1241,9 @@ def _load_native_core_build_manifest(
raise NativeCoreProtocolError(
"wechatdb native build manifest contains an invalid windowsClientSignerSha256."
)
+ elif manifest_platform == "linux":
+ # Linux 没有签名者证书;承载"客户端身份"的就是内容哈希 pin。
+ signer_digest = linux_client_sha256
else:
signer_digest = bytes(32)
macos_identifiers = (
@@ -1315,6 +1468,11 @@ def _load_native_core_build_manifest(
source_runtime=source_runtime,
windows_host_verification=windows_host_verification,
macos_host_verification=macos_host_verification,
+ linux_client_sha256=linux_client_sha256,
+ linux_broker_sha256=linux_broker_sha256,
+ linux_integrity_mode=linux_integrity_mode,
+ linux_peer_verification=linux_peer_verification,
+ linux_host_verification=linux_host_verification,
)
@@ -1333,6 +1491,32 @@ def _required_native_core_build_manifest(
"wechatdb native build manifest does not match the current platform."
)
frozen = bool(getattr(sys, "frozen", False))
+ if manifest.platform == "linux":
+ from .native_core_lease import validate_native_core_authorization_policy
+
+ # Linux 没有代码签名,身份是内容哈希 pin + 直接父进程的宿主校验;发布工作流发的
+ # 就是这一份受限 source-public 产物(linux-private-build.yml 只收 source-public)。
+ # 所以冻结应用必须消费 source-public —— 与 Windows 同一原则(Windows 的发布形态
+ # 同样是受限 source-public)。macOS 走真正的签名 production,冻结态仍只认 production。
+ if frozen and (
+ _is_production_native_core_build_manifest(manifest)
+ or _is_source_public_native_core_build_manifest(manifest)
+ ):
+ validate_native_core_authorization_policy(manifest)
+ return manifest
+ # 源码 checkout 只接受受限 source-public;production 与 dev-local 都不授权
+ # (与 macOS 同一原则)。
+ if not frozen and _is_source_public_native_core_build_manifest(manifest):
+ validate_native_core_authorization_policy(manifest)
+ return manifest
+ if not frozen:
+ raise NativeCoreProtocolError(
+ "Source WeChatDataAnalysis on Linux requires the exact restricted "
+ "source-public native core."
+ )
+ raise NativeCoreProtocolError(
+ "Frozen WeChatDataAnalysis requires a production wechatdb native core."
+ )
if manifest.platform == "macos":
if (
frozen
@@ -1439,6 +1623,11 @@ def _is_production_native_core_build_manifest_base(
== _NATIVE_CORE_OFFLINE_BOOTSTRAP_FEATURES
and manifest.offline_export_seal_format == "WES2"
and not _NATIVE_CORE_NON_PRODUCTION_BUILD_ID_PATTERN.search(manifest.build_id)
+ # Linux 用内容哈希 pin 代替签名者摘要,但 manifest 必须声明签发态。
+ and (
+ manifest.platform != "linux"
+ or manifest.linux_integrity_mode == "content-hash-pin"
+ )
)
@@ -1490,6 +1679,8 @@ def _is_source_public_native_core_build_manifest(
return False
if manifest.platform == "macos":
return manifest.macos_host_verification == "same-user-direct-parent"
+ if manifest.platform == "linux":
+ return manifest.linux_host_verification == "same-user-direct-parent"
return (
manifest.platform == "windows"
and manifest.windows_host_verification == "same-user-direct-parent"
@@ -1501,8 +1692,10 @@ def _manifest_matches_runtime_platform(
runtime_platform: str | None = None,
) -> bool:
current = sys.platform if runtime_platform is None else runtime_platform
- return (current.startswith("win") and manifest.platform == "windows") or (
- current == "darwin" and manifest.platform == "macos"
+ return (
+ (current.startswith("win") and manifest.platform == "windows")
+ or (current == "darwin" and manifest.platform == "macos")
+ or (current.startswith("linux") and manifest.platform == "linux")
)
@@ -1534,7 +1727,11 @@ def _native_library_name() -> str:
return "wechatdb_client.dll"
if sys.platform == "darwin":
return "libwechatdb_client.dylib"
- raise NativeCoreComponentMissingError("wechatdb native core supports Windows and macOS only.")
+ if sys.platform.startswith("linux"):
+ return "libwechatdb_client.so"
+ raise NativeCoreComponentMissingError(
+ "wechatdb native core supports Windows, macOS and Linux only."
+ )
def _candidate_library_paths() -> tuple[Path, ...]:
@@ -1559,6 +1756,8 @@ def _candidate_library_paths() -> tuple[Path, ...]:
repo_root.parent / "wechatdb-native" / "build" / "windows-vs" / "Debug" / file_name,
repo_root.parent / "wechatdb-native" / "build" / "windows-msvc-debug" / file_name,
repo_root.parent / "wechatdb-native" / "build" / "macos-arm64-debug" / file_name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-debug" / file_name,
+ repo_root.parent / "wechatdb-native" / "build" / "linux-x64-release" / file_name,
)
)
@@ -1589,17 +1788,18 @@ def resolve_native_core_library() -> Path:
def _native_core_broker_name() -> str:
if sys.platform.startswith("win"):
return "wechatdb_broker.exe"
- if sys.platform == "darwin":
+ # macOS 与 Linux 共用同一个可执行文件名(两者都是 ELF/Mach-O 裸二进制)。
+ if sys.platform == "darwin" or sys.platform.startswith("linux"):
return "wechatdb_broker"
raise NativeCoreComponentMissingError(
- "wechatdb native broker supports Windows and macOS only."
+ "wechatdb native broker supports Windows, macOS and Linux only."
)
def _native_core_entrypoint_directory() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent / "native"
- if sys.platform in {"darwin", "win32"}:
+ if sys.platform in {"darwin", "win32"} or sys.platform.startswith("linux"):
configured = str(os.environ.get(ENV_SOURCE_NATIVE_CORE_DIR, "") or "").strip()
if configured:
try:
diff --git a/src/wechat_decrypt_tool/native_core_device_credential.py b/src/wechat_decrypt_tool/native_core_device_credential.py
index d7d2c53b..0bf48123 100644
--- a/src/wechat_decrypt_tool/native_core_device_credential.py
+++ b/src/wechat_decrypt_tool/native_core_device_credential.py
@@ -12,6 +12,10 @@
from pathlib import Path
from typing import Callable
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives.ciphers.aead import AESGCM
+from cryptography.hazmat.primitives.kdf.hkdf import HKDF
+
from .app_paths import get_data_dir
from .native_core_client import NativeCoreProtocolError, NativeCoreUnavailableError
@@ -27,6 +31,12 @@
_ENTROPY_DOMAIN = b"WeChatDataAnalysis/native-core/device-credential/v2\0"
_MACOS_KEYCHAIN_MAGIC = b"WCEKC002"
_MACOS_KEYCHAIN_SERVICE = "com.lifearchive.wechatdataanalysis.native-core-credential.v2"
+# Linux 没有系统级 per-user keystore(DPAPI/Keychain 对应物),所以采用两种
+# Unix 惯例的组合:文件 0600(等同 SSH 私钥的卫生标准)+ 用 machine-id + uid
+# 派生密钥的 AEAD 信封(这样把文件拷到另一台机器/另一个用户下也解不开)。
+_LINUX_MAGIC = b"WCELDC1"
+_LINUX_AAD = b"WeChatDataAnalysis/native-core/device-credential/linux/v1"
+_LINUX_NONCE_BYTES = 12
CredentialTransform = Callable[[bytes, bytes], bytes]
BytesLike = bytes | bytearray | memoryview
@@ -203,6 +213,30 @@ def _parse_record(plaintext: bytes, *, expected_schema: int) -> StoredDeviceCred
raise NativeCoreProtocolError("Native core device credential is invalid.")
+def _linux_machine_identity() -> bytes:
+ """machine-id + uid:把凭据绑定到"这台机器上的这个用户"。"""
+ for candidate in ("/etc/machine-id", "/var/lib/dbus/machine-id"):
+ try:
+ value = Path(candidate).read_text(encoding="ascii").strip()
+ except OSError:
+ continue
+ if value:
+ return f"{value}:{os.getuid()}".encode("utf-8")
+ raise NativeCoreUnavailableError(
+ "Cannot determine the Linux machine identity for the native core device credential."
+ )
+
+
+def _linux_credential_key(entropy: bytes) -> bytes:
+ """entropy 作为 salt/AAD 绑定 device/build/service,拷到别处失效。"""
+ return HKDF(
+ algorithm=hashes.SHA256(),
+ length=32,
+ salt=entropy,
+ info=_LINUX_AAD,
+ ).derive(_linux_machine_identity())
+
+
def _protect_current_user(payload: bytes, entropy: bytes) -> bytes:
if sys.platform == "darwin":
account_digest = hashlib.sha256(
@@ -235,8 +269,15 @@ def _protect_current_user(payload: bytes, entropy: bytes) -> bytes:
from .native_core_raw_key_cache import _dpapi_transform
return _dpapi_transform(payload, entropy=entropy, protect=True)
+ if sys.platform.startswith("linux"):
+ nonce = os.urandom(_LINUX_NONCE_BYTES)
+ sealed = AESGCM(_linux_credential_key(entropy)).encrypt(
+ nonce, payload, _LINUX_AAD
+ )
+ return _LINUX_MAGIC + nonce + sealed
raise NativeCoreUnavailableError(
- "Native core device credentials require Windows DPAPI or macOS Keychain."
+ "Native core device credentials require Windows DPAPI, macOS Keychain or "
+ "the Linux machine-bound credential store."
)
@@ -288,8 +329,30 @@ def _unprotect_current_user(payload: bytes, entropy: bytes) -> bytes:
from .native_core_raw_key_cache import _dpapi_transform
return _dpapi_transform(payload, entropy=entropy, protect=False)
+ if sys.platform.startswith("linux"):
+ offset = len(_LINUX_MAGIC)
+ if (
+ len(payload) <= offset + _LINUX_NONCE_BYTES
+ or not payload.startswith(_LINUX_MAGIC)
+ ):
+ raise NativeCoreProtocolError(
+ "Native core Linux credential binding is invalid."
+ )
+ nonce = payload[offset : offset + _LINUX_NONCE_BYTES]
+ try:
+ return AESGCM(_linux_credential_key(entropy)).decrypt(
+ nonce, payload[offset + _LINUX_NONCE_BYTES :], _LINUX_AAD
+ )
+ except Exception as exc:
+ # 解不开通常意味着换机器/换用户/换 device-build-service 绑定,
+ # 与 macOS Keychain 不一致的情形等价:当作凭据失效处理。
+ raise NativeCoreProtocolError(
+ "Native core Linux device credential cannot be decrypted on this "
+ "machine or user."
+ ) from exc
raise NativeCoreUnavailableError(
- "Native core device credentials require Windows DPAPI or macOS Keychain."
+ "Native core device credentials require Windows DPAPI, macOS Keychain or "
+ "the Linux machine-bound credential store."
)
diff --git a/src/wechat_decrypt_tool/native_core_lease.py b/src/wechat_decrypt_tool/native_core_lease.py
index ef8d20df..c513db16 100644
--- a/src/wechat_decrypt_tool/native_core_lease.py
+++ b/src/wechat_decrypt_tool/native_core_lease.py
@@ -48,6 +48,7 @@
_PRODUCTION_APP_IDS = {
"windows": "wechat-data-analysis.windows",
"macos": "wechat-data-analysis.macos",
+ "linux": "wechat-data-analysis.linux",
}
_LICENSE_PROTOCOL_VERSION = 2
_MAX_RESPONSE_BYTES = 64 * 1024
diff --git a/src/wechat_decrypt_tool/platform_support.py b/src/wechat_decrypt_tool/platform_support.py
index 32c0d871..5c310f86 100644
--- a/src/wechat_decrypt_tool/platform_support.py
+++ b/src/wechat_decrypt_tool/platform_support.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import importlib.util
import json
import os
import platform
@@ -33,6 +34,10 @@ def is_windows() -> bool:
return current_platform() == "windows"
+def is_linux() -> bool:
+ return current_platform() == "linux"
+
+
def _native_root() -> Path:
return Path(__file__).resolve().parent / "native"
@@ -133,6 +138,39 @@ def mac_native_core_paths() -> tuple[Path, Path, Path]:
)
+def linux_native_core_paths() -> tuple[Path, Path, Path]:
+ """Linux 的 client 是 .so,broker 与 macOS 同名(同为裸可执行文件)。"""
+ return (
+ _first_existing_native_resource(
+ Path("libwechatdb_client.so"),
+ explicit=str(
+ os.environ.get("WECHAT_TOOL_NATIVE_CORE_LIBRARY", "") or ""
+ ).strip(),
+ ),
+ _first_existing_native_resource(
+ Path("wechatdb_broker"),
+ explicit=str(
+ os.environ.get("WECHAT_TOOL_NATIVE_CORE_BROKER", "") or ""
+ ).strip(),
+ ),
+ _first_existing_native_resource(Path("wechatdb_native_build.json")),
+ )
+
+
+def _linux_native_core_manifest_ready(manifest: dict[str, Any]) -> bool:
+ """Linux 只认 source-public profile(与 Windows/macOS 同一原则)。
+
+ 必须与 native_core_client 的授权策略保持一致:那边会拒掉 production
+ profile,这里就不能报"可用",否则界面说可用、一调用就报错。
+ """
+ return (
+ manifest.get("linuxIntegrityMode") == "content-hash-pin"
+ and manifest.get("linuxPeerVerification") == "same-user-peer-credentials"
+ and manifest.get("sourceRuntime") is True
+ and manifest.get("linuxHostVerification") == "same-user-direct-parent"
+ )
+
+
def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool:
client, broker, manifest_path = paths
try:
@@ -153,6 +191,8 @@ def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool:
return False
if manifest.get("schemaVersion") == 2 and "platform" not in manifest:
return True
+ if manifest.get("schemaVersion") == 4 and manifest.get("platform") == "linux":
+ return _linux_native_core_manifest_ready(manifest)
if manifest.get("schemaVersion") != 3 or manifest.get("platform") != "macos":
return False
source_fields = {
@@ -167,13 +207,31 @@ def _native_core_resources_ready(paths: tuple[Path, Path, Path]) -> bool:
)
+def _linux_wx_key_available() -> bool:
+ """Linux 的密钥获取全部依赖 wx_key(hook 模式,由它 fork 拉起微信)。
+
+ 不 import,只用 find_spec 探测,避免能力查询带起原生模块加载。
+ """
+ try:
+ return importlib.util.find_spec("wx_key") is not None
+ except (ImportError, ValueError):
+ return False
+
+
def runtime_capabilities() -> dict[str, Any]:
system = current_platform()
architecture = (platform.machine() or "unknown").lower()
apple_silicon = system == "macos" and architecture in {"arm64", "aarch64"}
+ linux_key_ready = system == "linux" and _linux_wx_key_available()
helper = mac_image_scan_helper_path() if system == "macos" else None
image_scan_library = mac_image_scan_library_path() if system == "macos" else None
- native_core_paths = mac_native_core_paths() if system == "macos" else None
+ native_core_paths = (
+ mac_native_core_paths()
+ if system == "macos"
+ else linux_native_core_paths()
+ if system == "linux"
+ else None
+ )
image_scan_ready = bool(
helper
and image_scan_library
@@ -181,8 +239,9 @@ def runtime_capabilities() -> dict[str, Any]:
and image_scan_library.is_file()
and helper.parent.resolve() == image_scan_library.parent.resolve()
)
+ # macOS 的实时 WCDB 仅支持 Apple Silicon;Linux 没有架构门槛(x86_64 基线)。
realtime_ready = bool(
- apple_silicon
+ (system != "macos" or apple_silicon)
and native_core_paths
and _native_core_resources_ready(native_core_paths)
)
@@ -209,7 +268,11 @@ def runtime_capabilities() -> dict[str, Any]:
"platform_release": platform.release(),
"architecture": architecture,
"apple_silicon": apple_silicon,
- "database_key_extraction": system == "windows" or bool(mac_db_key_status["available"]),
+ "database_key_extraction": (
+ system == "windows"
+ or bool(mac_db_key_status["available"])
+ or linux_key_ready
+ ),
"macos_lldb_fallback": macos_lldb_fallback,
"macos_lldb_fallback_note": (
"实验性本机调试兜底仅支持 Apple Silicon Mac,并需要安装 Xcode Command Line Tools。"
@@ -218,10 +281,12 @@ def runtime_capabilities() -> dict[str, Any]:
),
"database_key_manual_input": True,
"database_decryption": True,
- "image_key_memory_scan": system == "windows" or image_scan_ready,
+ "image_key_memory_scan": system == "windows" or image_scan_ready or linux_key_ready,
"image_key_memory_scan_note": (
"macOS 图片密钥扫描原生资源缺失或安装不完整,请重新安装完整发行包。"
if system == "macos" and not image_scan_ready
+ else "Linux 图片密钥获取依赖 wx_key(本地算法),未检测到该模块。"
+ if system == "linux" and not linux_key_ready
else ""
),
"realtime_wcdb": system == "windows" or realtime_ready,
@@ -230,6 +295,8 @@ def runtime_capabilities() -> dict[str, Any]:
if system == "macos" and not apple_silicon
else "macOS 实时 WCDB 原生资源缺失,请重新安装完整发行包。"
if system == "macos" and not realtime_ready
+ else "Linux 实时 WCDB 需要受限 source-public 原生组件(内容哈希 pin + 构建有效期),组件缺失或不是该 profile 时就不可用。"
+ if system == "linux" and not realtime_ready
else ""
),
"wechat_process_media_hook": system == "windows",
@@ -239,6 +306,11 @@ def runtime_capabilities() -> dict[str, Any]:
"database_key_guidance": (
str(mac_db_key_status.get("note") or MAC_DB_KEY_GUIDANCE)
if system == "macos"
+ else "Linux 取密钥时会由 wx_key 拉起微信(免提权,走 fork + TRACEME),"
+ "请在弹出的微信里完成登录;Linux 不提供 V4 内存扫描(需要提权 attach),"
+ "取密钥只有 Hook 一条路。程序不能以 root 运行,否则 AppImage 版微信"
+ "会因 FUSE 对 root 不可见而打不开窗口。"
+ if system == "linux"
else ""
),
"database_key_build_id": (
@@ -255,11 +327,13 @@ def runtime_capabilities() -> dict[str, Any]:
__all__ = [
"MAC_DB_KEY_GUIDANCE",
"current_platform",
+ "is_linux",
"is_macos",
"is_windows",
"mac_image_scan_helper_path",
"mac_image_scan_library_path",
"mac_db_key_bundle_dir",
"mac_native_core_paths",
+ "linux_native_core_paths",
"runtime_capabilities",
]
diff --git a/src/wechat_decrypt_tool/routers/keys.py b/src/wechat_decrypt_tool/routers/keys.py
index 3e9b1a4f..b8bfa48f 100644
--- a/src/wechat_decrypt_tool/routers/keys.py
+++ b/src/wechat_decrypt_tool/routers/keys.py
@@ -28,7 +28,7 @@
)
from ..media_helpers import _load_media_keys, _resolve_account_dir
from ..path_fix import PathFixRoute
-from ..platform_support import current_platform, is_macos, runtime_capabilities
+from ..platform_support import current_platform, is_macos, is_windows, runtime_capabilities
router = APIRouter(route_class=PathFixRoute)
logger = get_logger(__name__)
@@ -573,7 +573,10 @@ async def watch_disconnect() -> None:
},
}
mode = str(key_mode or "auto").strip().lower()
- if mode in {"v4", "key_v4", "memory", "memory_scan"}:
+ # V4 内存扫描只存在于 Windows。Linux 的 Hook 走 fork + TRACEME(免提权),
+ # 根本不存在「先扫内存失败、再改用 Hook」这套流程;若这里仍然返回
+ # can_fallback_to_hook,前端就会弹一次永远不可能成功的引导弹窗。
+ if is_windows() and mode in {"v4", "key_v4", "memory", "memory_scan"}:
return {
"status": -2,
"errmsg": f"扫内存失败: {str(e)}",
@@ -613,7 +616,10 @@ async def watch_disconnect() -> None:
},
}
mode = str(key_mode or "auto").strip().lower()
- if mode in {"v4", "key_v4", "memory", "memory_scan"}:
+ # V4 内存扫描只存在于 Windows。Linux 的 Hook 走 fork + TRACEME(免提权),
+ # 根本不存在「先扫内存失败、再改用 Hook」这套流程;若这里仍然返回
+ # can_fallback_to_hook,前端就会弹一次永远不可能成功的引导弹窗。
+ if is_windows() and mode in {"v4", "key_v4", "memory", "memory_scan"}:
return {
"status": -2,
"errmsg": f"扫内存失败: {str(e)}",
diff --git a/src/wechat_decrypt_tool/wcdb_realtime.py b/src/wechat_decrypt_tool/wcdb_realtime.py
index 64bde3dd..07d8cab7 100644
--- a/src/wechat_decrypt_tool/wcdb_realtime.py
+++ b/src/wechat_decrypt_tool/wcdb_realtime.py
@@ -475,7 +475,12 @@ def get_status(self, account_dir: Path) -> dict[str, Any]:
client_path = native_dir / "wechatdb_client.dll"
broker_path = native_dir / "wechatdb_broker.exe"
else:
- client_path = native_dir / "libwechatdb_client.dylib"
+ client_path = native_dir / (
+ "libwechatdb_client.so"
+ if sys.platform.startswith("linux")
+ else "libwechatdb_client.dylib"
+ )
+ # macOS 与 Linux 的 broker 可执行文件名相同。
broker_path = native_dir / "wechatdb_broker"
manifest_path = client_path.with_name("wechatdb_native_build.json")
components_present = all(
diff --git a/src/wechat_decrypt_tool/wechat_detection.py b/src/wechat_decrypt_tool/wechat_detection.py
index bda63d25..f0ad5940 100644
--- a/src/wechat_decrypt_tool/wechat_detection.py
+++ b/src/wechat_decrypt_tool/wechat_detection.py
@@ -356,7 +356,22 @@ def get_process_list():
def _wechat_process_targets() -> set[str]:
- return {"wechat"} if sys.platform == "darwin" else {"weixin.exe", "wechat.exe"}
+ if sys.platform == "darwin":
+ return {"wechat"}
+ if sys.platform.startswith("linux"):
+ # Linux 版微信的进程名就是 wechat(AppImage 解包后同样如此)。
+ return {"wechat", "wechat-bin"}
+ return {"weixin.exe", "wechat.exe"}
+
+
+# Linux 微信可执行文件的标准位置:发行版包是 /usr/bin/wechat(符号链接到
+# /opt/wechat/wechat),手工安装可能在 ~/.local/bin。
+_LINUX_WECHAT_EXECUTABLE_PATHS = (
+ "/usr/bin/wechat",
+ "/opt/wechat/wechat",
+ "/usr/local/bin/wechat",
+ "~/.local/bin/wechat",
+)
def _is_wechat_dir_candidate_name(name: str) -> bool:
@@ -444,6 +459,18 @@ def add(path_value: str | None) -> None:
add(str(container_root / "Documents" / "xwechat_files"))
return scan_paths
+ if sys.platform.startswith("linux"):
+ # Linux 版微信 4.x 的数据落在“文档目录”下的 xwechat_files//db_storage。
+ # 除 XDG 文档目录外,也兼容解包目录/自定义安装把数据放到家目录或
+ # ~/.local/share 的情形。
+ add(os.path.join(home_dir, "Documents", "xwechat_files"))
+ add(os.path.join(home_dir, "xwechat_files"))
+ add(os.path.join(home_dir, ".local", "share", "xwechat_files"))
+ xdg_documents = str(os.environ.get("XDG_DOCUMENTS_DIR") or "").strip()
+ if xdg_documents:
+ add(os.path.join(xdg_documents, "xwechat_files"))
+ return scan_paths
+
user_profile = str(os.environ.get("USERPROFILE") or "").strip()
if user_profile:
add(user_profile)
@@ -1089,7 +1116,11 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A
# 尝试获取版本信息
try:
- if sys.platform == "darwin":
+ if sys.platform.startswith("linux"):
+ # Linux 上没有 PE 版本资源/Info.plist 可读,版本号只用于
+ # 展示,留空即可(不要走到 win32api 那支去制造噪音)。
+ version = ""
+ elif sys.platform == "darwin":
info_plist = Path(result["wechat_install_path"]) / "Contents" / "Info.plist"
with info_plist.open("rb") as stream:
info = plistlib.load(stream)
@@ -1128,6 +1159,19 @@ def detect_wechat_installation(data_root_path: str | None = None) -> Dict[str, A
except (OSError, ValueError):
pass
break
+ elif sys.platform.startswith("linux"):
+ # 未运行时按标准位置兜底;/usr/bin/wechat 是符号链接,resolve() 后
+ # 取父目录就是真正的安装目录(例如 /opt/wechat)。
+ for candidate in _LINUX_WECHAT_EXECUTABLE_PATHS:
+ executable = Path(candidate).expanduser()
+ if not executable.is_file():
+ continue
+ result["wechat_exe_path"] = str(executable)
+ result["wechat_install_path"] = str(executable.resolve().parent)
+ result["detection_methods"].append(
+ f"标准位置检测到微信: {executable}"
+ )
+ break
# 2. 使用新的账号检测逻辑:同时支持 Backup 与登录信息目录,并合并结果
result["detection_methods"].append("多账户检测(多来源合并)")
diff --git a/tests/test_linux_db_key_flow.py b/tests/test_linux_db_key_flow.py
new file mode 100644
index 00000000..475e3581
--- /dev/null
+++ b/tests/test_linux_db_key_flow.py
@@ -0,0 +1,98 @@
+import asyncio
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from wechat_decrypt_tool import key_service
+from wechat_decrypt_tool.routers import keys as keys_router
+
+
+V4_MODE_ERROR = "Linux 暂不支持 V4 内存扫描获取密钥(需要提权 attach 微信进程),请使用 hook 模式。"
+
+
+def _as_linux(test_case) -> None:
+ """把平台判定固定成 Linux,使断言不依赖跑测试的机器。"""
+ for entry in (
+ patch.object(key_service, "is_macos", return_value=False),
+ patch.object(key_service, "is_linux", return_value=True),
+ patch.object(key_service, "is_windows", return_value=False),
+ patch.object(keys_router, "is_macos", return_value=False),
+ # keys 路由没有导入 is_linux:它只区分「是不是 macOS」与「是不是 Windows」。
+ patch.object(keys_router, "is_windows", return_value=False),
+ ):
+ test_case.enterContext(entry)
+
+
+class TestLinuxDbKeyFlow(unittest.TestCase):
+ def test_linux_key_v4_request_never_offers_a_hook_fallback_dialog(self) -> None:
+ """Linux 没有 V4 内存扫描:不得回报 can_fallback_to_hook。
+
+ 该字段是前端「内存扫描失败,是否改用 Hook?」弹窗的唯一触发条件;Linux 的
+ Hook(fork + TRACEME)并不需要这种两段式兜底,回报它会让用户看到一次
+ 永远不可能成功的引导。
+ """
+ _as_linux(self)
+ with patch.object(keys_router, "get_db_key_workflow", side_effect=RuntimeError(V4_MODE_ERROR)):
+ result = asyncio.run(
+ keys_router.get_wechat_db_key(
+ request=None,
+ db_storage_path="/tmp/db_storage",
+ key_mode="key_v4",
+ )
+ )
+
+ self.assertEqual(result["status"], -1)
+ self.assertNotIn("can_fallback_to_hook", result["data"])
+ self.assertIn("hook", result["errmsg"])
+
+ def test_windows_key_v4_request_keeps_the_hook_fallback_dialog(self) -> None:
+ """Windows 的两段式流程必须保持不变。"""
+ with (
+ patch.object(keys_router, "is_macos", return_value=False),
+ patch.object(keys_router, "is_windows", return_value=True),
+ patch.object(keys_router, "get_db_key_workflow", side_effect=RuntimeError("scan failed")),
+ ):
+ result = asyncio.run(
+ keys_router.get_wechat_db_key(
+ request=None,
+ db_storage_path="D:/xwechat_files/wxid/db_storage",
+ key_mode="key_v4",
+ )
+ )
+
+ self.assertEqual(result["status"], -2)
+ self.assertTrue(result["data"]["can_fallback_to_hook"])
+ self.assertEqual(result["data"]["method"], "key_v4")
+
+ def test_linux_key_v4_mode_is_rejected_before_any_memory_scan(self) -> None:
+ """core 层也必须拒绝 v4:Linux 只有 hook 一条路。"""
+ _as_linux(self)
+ with self.assertRaises(RuntimeError) as context:
+ key_service.get_db_key_workflow(key_mode="key_v4")
+
+ self.assertIn("hook", str(context.exception))
+
+ def test_linux_auto_mode_goes_straight_to_hook(self) -> None:
+ _as_linux(self)
+ fetcher = MagicMock()
+ fetcher.fetch_db_key.return_value = {"db_key": "a" * 64}
+ with patch.object(key_service, "WeChatKeyFetcher", return_value=fetcher):
+ result = key_service.get_db_key_workflow(key_mode="auto")
+
+ fetcher.fetch_db_key.assert_called_once()
+ self.assertEqual(result["method"], "hook")
+ self.assertEqual(result["db_key"], "a" * 64)
+
+ def test_linux_unknown_mode_is_rejected(self) -> None:
+ _as_linux(self)
+ with self.assertRaises(RuntimeError):
+ key_service.get_db_key_workflow(key_mode="not-a-mode")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_linux_db_key_frontend.py b/tests/test_linux_db_key_frontend.py
new file mode 100644
index 00000000..f4cfe4a7
--- /dev/null
+++ b/tests/test_linux_db_key_frontend.py
@@ -0,0 +1,48 @@
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_frontend(path: str) -> str:
+ return (ROOT / "frontend" / path).read_text(encoding="utf-8")
+
+
+def _linux_hook_branch(source: str) -> str:
+ """截出 handleGetDbKey 里 Linux 的那一段分支(不含后续 Windows 分支)。"""
+ branch = source.split("if (isLinux.value) {", 1)[1]
+ return branch.split("} else if (dbStoragePath) {", 1)[0]
+
+
+def test_decrypt_page_skips_v4_memory_scan_on_linux() -> None:
+ """Linux 取密钥不尝试内存扫描:直接走 Hook,也不提示「内存扫描失败」。"""
+ source = read_frontend("pages/decrypt.vue")
+
+ linux_branch = _linux_hook_branch(source)
+ assert "await fetchByHook()" in linux_branch
+ # 不给后端发 V4 请求('key_v4' 才是请求体里的字面量),也不需要数据库路径来验证候选。
+ assert "'key_v4'" not in linux_branch
+ assert "dbStoragePath" not in linux_branch
+
+
+def test_decrypt_page_guide_dialog_tells_linux_users_the_truth() -> None:
+ source = read_frontend("pages/decrypt.vue")
+
+ linux_dialog = source.split("await requestGuideDialog(isLinux.value", 1)[1].split(": {", 1)[0]
+ assert "Linux 不执行内存扫描" in linux_dialog
+ # Linux 分支不能承诺「先扫内存、失败再改用 Hook」。
+ assert "如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。" not in linux_dialog
+ # 该文案必须仍然保留给 Windows 分支。
+ assert "如果内存扫描失败,系统会再次询问是否切换到 Hook 获取。" in source
+
+
+def test_v4_copy_and_attribution_are_hidden_where_memory_scan_does_not_exist() -> None:
+ source = read_frontend("pages/decrypt.vue")
+
+ # 「优先使用 V4 内存扫描」的按钮提示只在 Windows 显示。
+ assert (
+ "'点击按钮将优先使用 V4 内存扫描获取【数据库解密密钥】;失败时会询问您是否改用 Hook。"
+ "您也可以手动输入已知的64位密钥。'"
+ ) in source
+ # V4 扫内存的技术出处说明不适用于 Linux。
+ assert 'v-if="!isMacos && !isLinux"' in source
diff --git a/tests/test_linux_native_core_policy.py b/tests/test_linux_native_core_policy.py
new file mode 100644
index 00000000..b7de2d2e
--- /dev/null
+++ b/tests/test_linux_native_core_policy.py
@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+import json
+import sys
+import time
+from pathlib import Path
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "src"))
+
+from wechat_decrypt_tool import native_core_client, native_core_lease
+
+
+ZERO = "0" * 64
+
+
+def linux_manifest(
+ *,
+ development: bool = False,
+ source_runtime: bool = False,
+ host_verification: str | None = None,
+) -> dict[str, object]:
+ """Linux 的 schema v4 清单(与 WCDB producer 的 CMake 模板同字段集)。"""
+ issued = int(time.time()) - 60
+ manifest: dict[str, object] = {
+ "schemaVersion": 4,
+ "platform": "linux",
+ "distributionMode": "public",
+ "buildId": "dev-local" if development else "linux-x64-20260915-abcd1234",
+ "buildIssuedAtUnix": 0 if development else issued,
+ "buildExpiresAtUnix": 0 if development else issued + 45 * 24 * 60 * 60,
+ "developmentBuild": development,
+ "offlineBootstrapFeatureBits": 0 if development else 3,
+ "offlineExportSealFormat": "none" if development else "WES2",
+ "codeSignatureEnforced": not development,
+ "rootPublicKeyCompiled": not development,
+ "testHooksEnabled": development,
+ "stagingPinnedSignerTrust": False,
+ "linuxIntegrityMode": "development" if development else "content-hash-pin",
+ "linuxClientSha256": ZERO if development else "aa" * 32,
+ "linuxBrokerSha256": ZERO if development else "bb" * 32,
+ "linuxPeerVerification": "same-user-peer-credentials",
+ "linuxHostVerification": host_verification
+ or ("same-user-direct-parent" if source_runtime else "content-hash-pin"),
+ "securityNoticeId": "WCE-AUTOMATED-ANALYSIS-NOTICE-V2",
+ "securityNoticeSha256": "55" * 32,
+ "securityCheckpointSetId": "WCE-AI-CHECKPOINT-SET-V3",
+ "securityCheckpointCount": 7,
+ "securityCheckpointSetSha256": "66" * 32,
+ }
+ if source_runtime:
+ manifest["sourceRuntime"] = True
+ return manifest
+
+
+def load_manifest(tmp_path: Path, payload: dict[str, object]):
+ component = tmp_path / "libwechatdb_client.so"
+ component.write_bytes(b"client")
+ component.with_name("wechatdb_native_build.json").write_text(
+ json.dumps(payload), encoding="utf-8"
+ )
+ return native_core_client._load_native_core_build_manifest(component)
+
+
+def authorize(tmp_path: Path, payload: dict[str, object], monkeypatch: pytest.MonkeyPatch, *, frozen: bool):
+ component = tmp_path / "libwechatdb_client.so"
+ component.write_bytes(b"client")
+ component.with_name("wechatdb_native_build.json").write_text(
+ json.dumps(payload), encoding="utf-8"
+ )
+ monkeypatch.setattr(native_core_client.sys, "platform", "linux")
+ monkeypatch.setattr(
+ native_core_lease,
+ "validate_native_core_authorization_policy",
+ lambda _manifest: None,
+ )
+ monkeypatch.setattr(native_core_client.sys, "frozen", frozen, raising=False)
+ return native_core_client._required_native_core_build_manifest(component)
+
+
+def test_linux_production_manifest_uses_content_hash_pins(tmp_path: Path) -> None:
+ manifest = load_manifest(tmp_path, linux_manifest())
+
+ assert manifest.platform == "linux"
+ assert manifest.linux_integrity_mode == "content-hash-pin"
+ assert manifest.linux_peer_verification == "same-user-peer-credentials"
+ # Linux 没有签名者证书,client_signer_sha256 就是 client 的内容哈希。
+ assert manifest.client_signer_sha256 == bytes.fromhex("aa" * 32)
+ assert manifest.linux_broker_sha256 == bytes.fromhex("bb" * 32)
+ assert native_core_client._is_production_native_core_build_manifest(manifest)
+ assert not native_core_client._is_source_public_native_core_build_manifest(manifest)
+
+
+def test_linux_source_public_manifest_retains_production_security(tmp_path: Path) -> None:
+ manifest = load_manifest(tmp_path, linux_manifest(source_runtime=True))
+
+ assert manifest.source_runtime is True
+ assert manifest.linux_host_verification == "same-user-direct-parent"
+ assert native_core_client._is_source_public_native_core_build_manifest(manifest)
+ assert not native_core_client._is_production_native_core_build_manifest(manifest)
+
+
+def test_linux_development_manifest_has_no_production_pins(tmp_path: Path) -> None:
+ manifest = load_manifest(tmp_path, linux_manifest(development=True))
+
+ assert manifest.linux_integrity_mode == "development"
+ assert manifest.client_signer_sha256 == bytes(32)
+ assert native_core_client._is_development_native_core_build_manifest(manifest)
+
+
+def test_linux_release_ships_source_public_and_the_frozen_app_consumes_it(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ """发布工作流只发 source-public,冻结应用必须能消费它(与 Windows 同一原则)。"""
+ payload = linux_manifest(source_runtime=True)
+
+ frozen = authorize(tmp_path, payload, monkeypatch, frozen=True)
+ assert frozen.source_runtime is True
+
+ source = authorize(tmp_path, payload, monkeypatch, frozen=False)
+ assert source.source_runtime is True
+
+
+def test_linux_runtime_authorization_matrix_is_bound_to_frozen_state(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ # production 只在冻结态被授权:源码 checkout 必须用受限 source-public。
+ with pytest.raises(
+ native_core_client.NativeCoreProtocolError,
+ match="requires the exact restricted source-public",
+ ):
+ authorize(tmp_path, linux_manifest(), monkeypatch, frozen=False)
+
+ # 冻结态接受 production(第二条签发路径)。
+ assert authorize(tmp_path, linux_manifest(), monkeypatch, frozen=True) is not None
+
+ # dev-local 在两种状态下都不授权(与 macOS 同一原则)。
+ with pytest.raises(
+ native_core_client.NativeCoreProtocolError,
+ match="requires the exact restricted source-public",
+ ):
+ authorize(tmp_path, linux_manifest(development=True), monkeypatch, frozen=False)
+ with pytest.raises(
+ native_core_client.NativeCoreProtocolError,
+ match="requires a production wechatdb native core",
+ ):
+ authorize(tmp_path, linux_manifest(development=True), monkeypatch, frozen=True)
+
+
+def test_linux_manifest_platform_cannot_cross_runtime_boundaries(tmp_path: Path) -> None:
+ linux = load_manifest(tmp_path, linux_manifest())
+
+ assert native_core_client._manifest_matches_runtime_platform(linux, "linux")
+ assert not native_core_client._manifest_matches_runtime_platform(linux, "darwin")
+ assert not native_core_client._manifest_matches_runtime_platform(linux, "win32")
+
+
+@pytest.mark.parametrize(
+ ("field", "value"),
+ (
+ # 宿主校验强度必须与 sourceRuntime 配对。
+ ("linuxHostVerification", "content-hash-pin"),
+ # 内容哈希不得为零,也不得让 client 与 broker 撞哈希。
+ ("linuxClientSha256", ZERO),
+ ("linuxBrokerSha256", "aa" * 32),
+ # Linux 清单不得夹带 Windows / macOS 的签名身份字段。
+ ("windowsClientSignerSha256", "11" * 32),
+ ("macosClientSignerSha256", "11" * 32),
+ ("platform", "macos"),
+ ),
+)
+def test_linux_source_public_manifest_rejects_identity_substitution(
+ tmp_path: Path, field: str, value: object
+) -> None:
+ payload = linux_manifest(source_runtime=True)
+ payload[field] = value
+ with pytest.raises(native_core_client.NativeCoreProtocolError):
+ load_manifest(tmp_path, payload)
+
+
+def test_linux_manifest_rejects_development_integrity_with_production_pins(
+ tmp_path: Path,
+) -> None:
+ payload = linux_manifest(development=True)
+ payload["linuxClientSha256"] = "aa" * 32
+ with pytest.raises(native_core_client.NativeCoreProtocolError):
+ load_manifest(tmp_path, payload)
diff --git a/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl
deleted file mode 100644
index c3b8f4bc..00000000
Binary files a/tools/key_wheels/wx_key-2.0.1-cp310-cp310-win_amd64.whl and /dev/null differ
diff --git a/tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl
deleted file mode 100644
index bf110df5..00000000
Binary files a/tools/key_wheels/wx_key-2.0.1-cp311-cp311-win_amd64.whl and /dev/null differ
diff --git a/tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl
deleted file mode 100644
index 9fcdd3fe..00000000
Binary files a/tools/key_wheels/wx_key-2.0.1-cp312-cp312-win_amd64.whl and /dev/null differ
diff --git a/tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl
deleted file mode 100644
index d0500ad3..00000000
Binary files a/tools/key_wheels/wx_key-2.0.1-cp313-cp313-win_amd64.whl and /dev/null differ
diff --git a/tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl b/tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl
deleted file mode 100644
index 07c1a0ff..00000000
Binary files a/tools/key_wheels/wx_key-2.0.1-cp314-cp314-win_amd64.whl and /dev/null differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl
new file mode 100644
index 00000000..2d17de1f
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-linux_x86_64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl
new file mode 100644
index 00000000..df16df52
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp310-cp310-win_amd64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl
new file mode 100644
index 00000000..74b426a4
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-linux_x86_64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl
new file mode 100644
index 00000000..a3485f03
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp311-cp311-win_amd64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl
new file mode 100644
index 00000000..b3e673ae
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-linux_x86_64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl
new file mode 100644
index 00000000..6e029023
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp312-cp312-win_amd64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl
new file mode 100644
index 00000000..797be581
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-linux_x86_64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl
new file mode 100644
index 00000000..92634d97
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp313-cp313-win_amd64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl
new file mode 100644
index 00000000..1706f134
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-linux_x86_64.whl differ
diff --git a/tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl
new file mode 100644
index 00000000..121c8c89
Binary files /dev/null and b/tools/key_wheels/wx_key-2.1.1-cp314-cp314-win_amd64.whl differ
diff --git a/tools/rebuild_wcdb_release.py b/tools/rebuild_wcdb_release.py
index b8ebf3e6..a5c610a9 100644
--- a/tools/rebuild_wcdb_release.py
+++ b/tools/rebuild_wcdb_release.py
@@ -10,6 +10,7 @@
from pathlib import Path
import re
import subprocess
+import tarfile
import tempfile
import time
import zipfile
@@ -17,6 +18,8 @@
REPOSITORY = "2977094657/WCDB"
LIFETIME_SECONDS = 45 * 24 * 60 * 60
+# component -> (workflow, artifact name, directory name, env prefix, manifest name,
+# archive suffix)
COMPONENTS = {
"windows-native": (
"windows-native-production.yml",
@@ -24,6 +27,7 @@
"wechatdb-native-windows-x64-source-public",
"WCE_NATIVE_CORE",
"wechatdb_native_build.json",
+ ".zip",
),
"macos-native": (
"macos-native-production.yml",
@@ -31,6 +35,7 @@
"wechatdb-native-macos-arm64-production",
"WCE_NATIVE_CORE",
"wechatdb_native_build.json",
+ ".zip",
),
"macos-xkey": (
"macos-key-capture-production.yml",
@@ -38,6 +43,7 @@
"wda-xkey",
"WCE_MACOS_XKEY",
"wda_xkey_build.json",
+ ".zip",
),
"macos-integrity": (
"macos-integrity-production.yml",
@@ -45,6 +51,17 @@
"wce-integrity-macos-arm64-production",
"WCE_INTEGRITY",
"wce_integrity_build.json",
+ ".zip",
+ ),
+ # Linux 没有代码签名,身份是内容哈希;发布形态是 source-public tar.gz,
+ # 与 Windows/macOS 的 source-public zip 同一条自动重建路线。
+ "linux-native": (
+ "linux-native-production.yml",
+ "wechatdb-native-linux-x64-source-public",
+ "wechatdb-native-linux-x64-source-public",
+ "WCE_NATIVE_CORE",
+ "wechatdb_native_build.json",
+ ".tar.gz",
),
}
@@ -118,10 +135,10 @@ def wait_for_build(build: dict, revision: str) -> int:
def download(build: dict, run_id: int, revision: str, issued_at: int, output_root: Path) -> dict:
component = build["component"]
- _, artifact_name, directory_name, prefix, manifest_name = COMPONENTS[component]
+ _, artifact_name, directory_name, prefix, manifest_name, suffix = COMPONENTS[component]
build_id = build["build_id"]
tag = f"{component}-{build_id}"
- asset_name = f"{artifact_name}-{build_id}.zip"
+ asset_name = f"{artifact_name}-{build_id}{suffix}"
release = api(f"releases/tags/{tag}")
if release.get("target_commitish") != revision:
raise RuntimeError(f"Producer Release target does not match {revision}: {tag}")
@@ -151,10 +168,24 @@ def download(build: dict, run_id: int, revision: str, issued_at: int, output_roo
if f"sha256:{digest}" != expected_digest:
raise RuntimeError(f"Producer Release asset digest mismatch: {asset_name}")
archive.seek(0)
- with zipfile.ZipFile(archive) as package:
- package.extractall(destination)
- if component in ("macos-native", "macos-xkey"):
- executable = "wechatdb_broker" if component == "macos-native" else "wda_xkey_helper"
+ if suffix == ".tar.gz":
+ # Linux 的 producer 用可复现的 tar.gz 封装同一份严格目录,
+ # 所以解包时沿用 tar 里记录的成员权限。
+ expand = getattr(tarfile, "data_filter", None)
+ with tarfile.open(fileobj=archive, mode="r:gz") as package:
+ if expand is None:
+ package.extractall(destination)
+ else:
+ package.extractall(destination, filter="data")
+ else:
+ with zipfile.ZipFile(archive) as package:
+ package.extractall(destination)
+ if component in ("macos-native", "macos-xkey", "linux-native"):
+ executable = {
+ "macos-native": "wechatdb_broker",
+ "macos-xkey": "wda_xkey_helper",
+ "linux-native": "wechatdb_broker",
+ }[component]
(destination / executable).chmod(0o755)
manifest = json.loads((destination / manifest_name).read_text(encoding="utf-8"))
if component.endswith("native"):
@@ -166,6 +197,20 @@ def download(build: dict, run_id: int, revision: str, issued_at: int, output_roo
or manifest.get("databaseWriteBuild") is not False
or manifest.get("wechatActions") != []):
raise RuntimeError("Release native core must be read-only")
+ if component == "linux-native":
+ # Linux 的唯一产物身份是这两组内容哈希,必须由 manifest 声明并逐字节成立。
+ for field in ("linuxClientSha256", "linuxBrokerSha256"):
+ if not re.fullmatch(r"[0-9a-f]{64}", str(manifest.get(field) or "")):
+ raise RuntimeError(f"Release Linux native core has no {field}")
+ for name, field in (
+ ("libwechatdb_client.so", "linuxClientSha256"),
+ ("wechatdb_broker", "linuxBrokerSha256"),
+ ):
+ with (destination / name).open("rb") as binary:
+ if hashlib.file_digest(binary, "sha256").hexdigest() != manifest[field]:
+ raise RuntimeError(f"Release Linux native core failed its {field} pin")
+ if manifest.get("linuxIntegrityMode") != "content-hash-pin":
+ raise RuntimeError("Release Linux native core is not content-hash-pin")
elif component == "macos-xkey":
identity = manifest["build"]["id"]
issued = manifest["build"]["issuedAtUnix"]
@@ -188,6 +233,11 @@ def download(build: dict, run_id: int, revision: str, issued_at: int, output_roo
f"{prefix}_BUILD_ID": identity,
f"{prefix}_ARTIFACT_DIR": str(destination),
}
+ if component == "linux-native":
+ # 消费方按平台顺序重新解析这两份内容哈希(linux-private-build.yml 也把它们
+ # 当成受保护 pin 再核一遍)。
+ values[f"{prefix}_CLIENT_SHA256"] = manifest["linuxClientSha256"]
+ values[f"{prefix}_BROKER_SHA256"] = manifest["linuxBrokerSha256"]
if component == "macos-integrity":
with (destination / "libwce_integrity.dylib").open("rb") as binary:
values["WCE_INTEGRITY_BINARY_SHA256"] = hashlib.file_digest(binary, "sha256").hexdigest()
diff --git a/uv.lock b/uv.lock
index 97210367..62b9cd1d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 2
+revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
@@ -3189,7 +3189,7 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uvicorn", extra = ["standard"] },
{ name = "watchfiles" },
- { name = "wx-key", marker = "sys_platform == 'win32'" },
+ { name = "wx-key", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "yara-python", marker = "sys_platform == 'win32'" },
{ name = "zstandard" },
]
@@ -3261,7 +3261,7 @@ requires-dist = [
{ name = "typing-extensions", specifier = ">=4.8.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
{ name = "watchfiles", specifier = ">=1.1.0" },
- { name = "wx-key", marker = "sys_platform == 'win32'", specifier = ">=2.0.1" },
+ { name = "wx-key", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=2.1.1" },
{ name = "yara-python", marker = "sys_platform == 'win32'", specifier = ">=4.5.2" },
{ name = "zstandard", specifier = ">=0.23.0" },
]
@@ -3281,13 +3281,17 @@ wheels = [
[[package]]
name = "wx-key"
-version = "2.0.1"
+version = "2.1.1"
source = { registry = "tools/key_wheels" }
wheels = [
- { path = "wx_key-2.0.1-cp311-cp311-win_amd64.whl" },
- { path = "wx_key-2.0.1-cp312-cp312-win_amd64.whl" },
- { path = "wx_key-2.0.1-cp313-cp313-win_amd64.whl" },
- { path = "wx_key-2.0.1-cp314-cp314-win_amd64.whl" },
+ { path = "wx_key-2.1.1-cp311-cp311-linux_x86_64.whl" },
+ { path = "wx_key-2.1.1-cp311-cp311-win_amd64.whl" },
+ { path = "wx_key-2.1.1-cp312-cp312-linux_x86_64.whl" },
+ { path = "wx_key-2.1.1-cp312-cp312-win_amd64.whl" },
+ { path = "wx_key-2.1.1-cp313-cp313-linux_x86_64.whl" },
+ { path = "wx_key-2.1.1-cp313-cp313-win_amd64.whl" },
+ { path = "wx_key-2.1.1-cp314-cp314-linux_x86_64.whl" },
+ { path = "wx_key-2.1.1-cp314-cp314-win_amd64.whl" },
]
[[package]]