fix(ota): verify mirrored asset hashes

This commit is contained in:
DIYgod 2026-04-10 19:47:21 +08:00
parent f176051cd0
commit 3ef0e1b494
2 changed files with 70 additions and 10 deletions

View File

@ -205,14 +205,15 @@ describe("listPublishedOtaReleases", () => {
describe("extractMirroredFiles", () => {
it("extracts only referenced files from a tar archive", async () => {
const iosBundle = textEncoder.encode("console.log('ios')")
const archiveBuffer = await createTarArchive([
{
name: "bundles/ios-main.js",
body: "console.log('ios')",
body: iosBundle,
},
{
name: "bundles/unused.js",
body: "console.log('unused')",
body: textEncoder.encode("console.log('unused')"),
},
])
@ -223,7 +224,7 @@ describe("extractMirroredFiles", () => {
ios: {
launchAsset: {
path: "bundles/ios-main.js",
sha256: "a".repeat(64),
sha256: await sha256Hex(iosBundle),
contentType: "application/javascript",
},
assets: [],
@ -236,7 +237,7 @@ describe("extractMirroredFiles", () => {
expect(files).toEqual([
{
key: "mobile/production/0.4.1/0.4.2/ios/bundles/ios-main.js",
body: textEncoder.encode("console.log('ios')"),
body: iosBundle,
contentType: "application/javascript",
},
])
@ -246,7 +247,7 @@ describe("extractMirroredFiles", () => {
const archiveBuffer = await createTarArchive([
{
name: "bundles/unused.js",
body: "console.log('unused')",
body: textEncoder.encode("console.log('unused')"),
},
])
@ -269,6 +270,34 @@ describe("extractMirroredFiles", () => {
}),
).rejects.toThrow('Archive is missing referenced file "bundles/ios-main.js"')
})
it("throws when a referenced archive file hash does not match metadata", async () => {
const archiveBuffer = await createTarArchive([
{
name: "bundles/ios-main.js",
body: textEncoder.encode("console.log('tampered')"),
},
])
await expect(
extractMirroredFiles({
release: {
...baseRelease,
platforms: {
ios: {
launchAsset: {
path: "bundles/ios-main.js",
sha256: await sha256Hex(textEncoder.encode("console.log('ios')")),
contentType: "application/javascript",
},
assets: [],
},
},
},
archiveBuffer,
}),
).rejects.toThrow('Archive file "bundles/ios-main.js" hash mismatch')
})
})
describe("putMirroredFiles", () => {
@ -371,7 +400,7 @@ describe("mirrorReleaseToStorage", () => {
async function createTarArchive(
entries: Array<{
name: string
body: string
body: Uint8Array
}>,
) {
const pack = tar.pack()
@ -389,11 +418,10 @@ async function createTarArchive(
for (const entry of entries) {
await new Promise<void>((resolve, reject) => {
const body = textEncoder.encode(entry.body)
const tarEntry = pack.entry(
{
name: entry.name,
size: body.byteLength,
size: entry.body.byteLength,
},
(error) => {
if (error) {
@ -406,7 +434,7 @@ async function createTarArchive(
)
tarEntry.on("error", reject)
tarEntry.end(body)
tarEntry.end(entry.body)
})
}
@ -427,3 +455,13 @@ function concatenateChunks(chunks: readonly Uint8Array[]) {
return output
}
async function sha256Hex(data: Uint8Array) {
const digest = await crypto.subtle.digest("SHA-256", toDigestInput(data))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
}
function toDigestInput(data: Uint8Array) {
return new Uint8Array(data)
}

View File

@ -12,6 +12,7 @@ interface MirroredFileRequest {
archivePath: string
key: string
contentType: string
sha256: string
body?: Uint8Array
}
@ -76,11 +77,21 @@ export async function extractMirroredFiles(input: {
stream.on("error", (error) => {
rejectOnce(toError(error))
})
stream.on("end", () => {
stream.on("end", async () => {
if (matchingRequests) {
const body = concatenateChunks(chunks)
const bodySha256 = await sha256Hex(body)
for (const request of matchingRequests) {
if (request.sha256 !== bodySha256) {
rejectOnce(
new Error(
`Archive file "${archivePath}" hash mismatch: expected ${request.sha256} but received ${bodySha256}`,
),
)
return
}
request.body = body
}
@ -151,6 +162,7 @@ function createMirroredFileRequests(release: OtaRelease): MirroredFileRequest[]
archivePath,
key: buildMirroredAssetKey(release, platform, archivePath),
contentType: asset.contentType,
sha256: asset.sha256,
})
}
}
@ -209,3 +221,13 @@ function once<T extends (...args: never[]) => void>(callback: T): T {
function toError(error: unknown) {
return error instanceof Error ? error : new Error(String(error))
}
async function sha256Hex(data: Uint8Array) {
const digest = await crypto.subtle.digest("SHA-256", toDigestInput(data))
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
}
function toDigestInput(data: Uint8Array) {
return new Uint8Array(data)
}