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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to this project will be documented in this file.

## 0.2.2 - 2026-07-13

- Fix vivo signature verification for multiline release notes and descriptions by
matching the CRLF normalization performed during multipart serialization.

## 0.2.1 - 2026-07-13

- Publish `shipup` to npm and document global installation.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "shipup",
"version": "0.2.1",
"version": "0.2.2",
"description": "Cross-platform release CLI for App Store Connect, AppGallery Connect, and Android app markets",
"type": "module",
"bin": {
Expand Down
19 changes: 17 additions & 2 deletions src/adapters/android/vivo/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,26 @@ function compareAsciiUpperCase(a: string, b: string): number {
*/
export function signVivo(params: Record<string, string>, accessSecret: string): string {
const str = Object.keys(params)
.filter((k) => k !== 'sign')
.sort(compareAsciiUpperCase)
.map((k) => `${k}=${params[k]}`)
.join('&');
return hmacSha256Hex(accessSecret, str);
}

/**
* WHATWG FormData 会在 multipart 序列化时把文本字段中的 CR/LF 统一为 CRLF。
* vivo 服务端按实际收到的字段值重算签名,因此必须在签名前做相同规范化;
* 否则多行 updateDesc/detailDesc 会因签名原文与线上字节不一致而返回 10001。
*/
function normalizeMultipartParams(params: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(params)
.filter(([k]) => k !== 'sign')
.map(([k, v]) => [k, v.replace(/\r\n|\r|\n/g, '\r\n')]),
);
}

/** 网关公共参数(timestamp 为毫秒,与 QQ 的秒不同)。 */
function commonParams(accessKey: string, method: string): Record<string, string> {
return {
Expand All @@ -66,9 +80,10 @@ async function postMultipart(
timeoutMs: number,
file?: { field: string; bytes: Uint8Array; filename: string },
): Promise<any> {
params.sign = signVivo(params, accessSecret);
const signedParams = normalizeMultipartParams(params);
signedParams.sign = signVivo(signedParams, accessSecret);
const form = new FormData();
for (const [k, v] of Object.entries(params)) form.append(k, v);
for (const [k, v] of Object.entries(signedParams)) form.append(k, v);
if (file) {
form.append(file.field, new Blob([file.bytes]), file.filename);
}
Expand Down
55 changes: 55 additions & 0 deletions test/vivo.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { signVivo, syncUpdateApp } from "../dist/adapters/android/vivo/api.js";

test("vivo multipart 签名使用实际发送的 CRLF 多行文本", async () => {
const originalFetch = globalThis.fetch;
const originalNow = Date.now;
let requested = false;

Date.now = () => 1_700_000_000_000;
globalThis.fetch = async (_url, init) => {
requested = true;
assert.ok(init.body instanceof FormData);

const params = {};
for (const [key, value] of init.body.entries()) {
assert.equal(typeof value, "string");
params[key] = String(value);
}

const signature = params.sign;
delete params.sign;
assert.equal(params.updateDesc, "第一行\r\n第二行\r\n第三行\r\n第四行");
assert.equal(signature, signVivo(params, "secret"));

return new Response(JSON.stringify({ code: 0, subCode: "0", msg: "成功" }), {
status: 200,
});
};

try {
await syncUpdateApp({
accessKey: "access-key",
accessSecret: "secret",
packageName: "com.example.app",
versionCode: "123",
apkMd5: "0123456789abcdef0123456789abcdef",
apkSerialNumber: "apk-serial",
updateDesc: "第一行\n第二行\r第三行\r\n第四行",
timeoutMs: 10_000,
});
} finally {
globalThis.fetch = originalFetch;
Date.now = originalNow;
}

assert.equal(requested, true);
});

test("vivo 重签时不把旧 sign 参与签名", () => {
assert.equal(
signVivo({ a: "1", sign: "old-signature" }, "secret"),
signVivo({ a: "1" }, "secret"),
);
});
Loading