import { createHash } from 'node:crypto'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { deflateRawSync } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertAppImageRuntimeMetadata,
expectedAppImageName,
expectedZsyncName,
inspectAppImageRuntime,
inspectEmbeddedBlockmap,
nativeUpdateInformation,
parseAppImageRuntime,
stripEmbeddedBlockmap,
verifyZsyncFile,
writeAppImageUpdateInformation,
} from '../../scripts/appimage-artifact.mjs'
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true }))
)
})
describe('AppImage native update metadata', () => {
it('uses latest for stable and latest-pre for beta, per architecture', () => {
expect(nativeUpdateInformation('2.0.0', 'x64')).toBe(
'gh-releases-zsync|agalwood|Motrix|latest|Motrix-*-x86_64.AppImage.zsync'
)
expect(nativeUpdateInformation('2.0.0-beta.23', 'arm64')).toBe(
'gh-releases-zsync|agalwood|Motrix|latest-pre|Motrix-*-arm64.AppImage.zsync'
)
expect(expectedAppImageName('2.0.0', 'x64')).toBe(
'Motrix-2.0.0-x86_64.AppImage'
)
expect(expectedZsyncName('2.0.0', 'arm64')).toBe(
'Motrix-2.0.0-arm64.AppImage.zsync'
)
})
it('rejects unsupported release channels and architectures', () => {
expect(() => nativeUpdateInformation('2.0.0-rc.1', 'x64')).toThrow(
'Unsupported AppImage update channel'
)
expect(() => nativeUpdateInformation('2.0.0', 'armv7l')).toThrow(
'Unsupported AppImage arch'
)
})
})
describe('static AppImage runtime inspection', () => {
it('accepts a static runtime and reads empty update/signature sections', () => {
const runtime = makeRuntime()
const inspection = parseAppImageRuntime(runtime, 'x64')
expect(inspection.runtimeBytes).toBe(runtime.length)
expect(() =>
assertAppImageRuntimeMetadata(
{ runtime, ...inspection },
{ updateInformation: '', requireUnsigned: true }
)
).not.toThrow()
})
it('rejects an interpreter, dynamic dependency, and FUSE2 loader string', () => {
const interpreted = makeRuntime()
interpreted.writeUInt32LE(3, 64)
expect(() => parseAppImageRuntime(interpreted, 'x64')).toThrow('PT_INTERP')
const needed = makeRuntime()
needed.writeBigInt64LE(1n, RUNTIME_OFFSETS.dynamic)
expect(() => parseAppImageRuntime(needed, 'x64')).toThrow('DT_NEEDED')
const fuse2 = makeRuntime()
fuse2.write('libfuse.so.2', RUNTIME_OFFSETS.padding, 'utf8')
expect(() => parseAppImageRuntime(fuse2, 'x64')).toThrow('libfuse.so.2')
})
it('rejects populated native signature bytes', () => {
const runtime = makeRuntime()
runtime[RUNTIME_OFFSETS.signature] = 1
const inspection = parseAppImageRuntime(runtime, 'x64')
expect(() =>
assertAppImageRuntimeMetadata(
{ runtime, ...inspection },
{ updateInformation: '', requireUnsigned: true }
)
).toThrow('native signature is populated')
})
it('writes update information into the fixed ELF section', async () => {
const directory = await makeTempDir()
const file = path.join(directory, 'Motrix-2.0.0-x86_64.AppImage')
await writeFile(file, makeRuntime())
await chmod(file, 0o755)
const update = nativeUpdateInformation('2.0.0', 'x64')
await writeAppImageUpdateInformation(file, 'x64', update)
const inspection = await inspectAppImageRuntime(file, 'x64')
expect(() =>
assertAppImageRuntimeMetadata(inspection, {
updateInformation: update,
requireUnsigned: true,
})
).not.toThrow()
})
})
describe('embedded blockmap inspection', () => {
it('validates and strips the electron-updater blockmap footer', async () => {
const directory = await makeTempDir()
const file = path.join(directory, 'Motrix.AppImage')
const base = Buffer.from('complete AppImage bytes')
const document = {
version: '2',
files: [
{
name: 'file',
offset: 0,
checksums: ['fixture-checksum'],
sizes: [base.length],
},
],
}
const compressed = deflateRawSync(Buffer.from(JSON.stringify(document)))
const footer = Buffer.alloc(4)
footer.writeUInt32BE(compressed.length)
await writeFile(file, Buffer.concat([base, compressed, footer]))
await expect(inspectEmbeddedBlockmap(file)).resolves.toMatchObject({
baseSize: base.length,
blockMapSize: compressed.length,
})
await stripEmbeddedBlockmap(file, compressed.length)
await expect(readFile(file)).resolves.toEqual(base)
})
it('rejects malformed blockmap metadata', async () => {
const directory = await makeTempDir()
const file = path.join(directory, 'Motrix.AppImage')
await writeFile(file, Buffer.from('not a blockmap\0\0\0\0'))
await expect(inspectEmbeddedBlockmap(file)).rejects.toThrow(
'Invalid embedded AppImage blockmap size'
)
})
})
describe('zsync verification', () => {
it('binds Filename, URL, Length, and SHA-1 to the final AppImage', async () => {
const directory = await makeTempDir()
const appImagePath = path.join(directory, 'Motrix-2.0.0-x86_64.AppImage')
const zsyncPath = `${appImagePath}.zsync`
const content = Buffer.from('final AppImage including embedded blockmap')
await writeFile(appImagePath, content)
await writeFile(zsyncPath, makeZsync(path.basename(appImagePath), content))
await expect(verifyZsyncFile({ appImagePath, zsyncPath })).resolves.toEqual(
{ zsync: path.basename(zsyncPath) }
)
const changed = Buffer.from('changed AppImage')
await writeFile(appImagePath, changed)
await expect(verifyZsyncFile({ appImagePath, zsyncPath })).rejects.toThrow(
'Length does not match AppImage'
)
})
it('accepts the current File-Hash form as well as legacy SHA-1', async () => {
const directory = await makeTempDir()
const appImagePath = path.join(directory, 'Motrix-2.0.0-arm64.AppImage')
const zsyncPath = `${appImagePath}.zsync`
const content = Buffer.from('arm64 AppImage')
await writeFile(appImagePath, content)
await writeFile(
zsyncPath,
makeZsync(path.basename(appImagePath), content).replace(
/SHA-1: ([0-9a-f]{40})/u,
'File-Hash: SHA-1:$1'
)
)
await expect(
verifyZsyncFile({ appImagePath, zsyncPath })
).resolves.toMatchObject({ zsync: path.basename(zsyncPath) })
})
})
const RUNTIME_OFFSETS = {
names: 128,
static: 256,
padding: 300,
update: 512,
signature: 1536,
dynamic: 2560,
sections: 4096,
}
function makeRuntime() {
const names = Buffer.from(
'\0.shstrtab\0.static\0.upd_info\0.sha256_sig\0.dynamic\0'
)
const sectionCount = 6
const runtime = Buffer.alloc(RUNTIME_OFFSETS.sections + sectionCount * 64)
Buffer.from([0x7f, 0x45, 0x4c, 0x46]).copy(runtime)
runtime[4] = 2
runtime[5] = 1
runtime[6] = 1
runtime[8] = 0x41
runtime[9] = 0x49
runtime[10] = 0x02
runtime.writeUInt16LE(2, 16)
runtime.writeUInt16LE(0x3e, 18)
runtime.writeUInt32LE(1, 20)
runtime.writeBigUInt64LE(64n, 0x20)
runtime.writeBigUInt64LE(BigInt(RUNTIME_OFFSETS.sections), 0x28)
runtime.writeUInt16LE(64, 0x34)
runtime.writeUInt16LE(56, 0x36)
runtime.writeUInt16LE(1, 0x38)
runtime.writeUInt16LE(64, 0x3a)
runtime.writeUInt16LE(sectionCount, 0x3c)
runtime.writeUInt16LE(1, 0x3e)
runtime.writeUInt32LE(1, 64)
names.copy(runtime, RUNTIME_OFFSETS.names)
runtime.write('static', RUNTIME_OFFSETS.static, 'utf8')
Buffer.from(
'--appimage-mount\0--appimage-extract\0--appimage-extract-and-run\0TARGET_APPIMAGE'
).copy(runtime, RUNTIME_OFFSETS.padding)
writeSection(
runtime,
1,
names,
'.shstrtab',
3,
RUNTIME_OFFSETS.names,
names.length
)
writeSection(runtime, 2, names, '.static', 1, RUNTIME_OFFSETS.static, 6)
writeSection(runtime, 3, names, '.upd_info', 1, RUNTIME_OFFSETS.update, 1024)
writeSection(
runtime,
4,
names,
'.sha256_sig',
1,
RUNTIME_OFFSETS.signature,
1024
)
writeSection(runtime, 5, names, '.dynamic', 6, RUNTIME_OFFSETS.dynamic, 16)
return runtime
}
function writeSection(
runtime: Buffer,
index: number,
names: Buffer,
name: string,
type: number,
offset: number,
size: number
) {
const header = RUNTIME_OFFSETS.sections + index * 64
runtime.writeUInt32LE(names.indexOf(Buffer.from(`${name}\0`)), header)
runtime.writeUInt32LE(type, header + 4)
runtime.writeBigUInt64LE(BigInt(offset), header + 24)
runtime.writeBigUInt64LE(BigInt(size), header + 32)
}
function makeZsync(name: string, appImage: Buffer) {
const sha1 = createHash('sha1').update(appImage).digest('hex')
return (
`zsync: 0.6.2\n` +
`Filename: ${name}\n` +
`Blocksize: 2048\n` +
`Length: ${appImage.length}\n` +
`Hash-Lengths: 1,2,4\n` +
`URL: ${name}\n` +
`SHA-1: ${sha1}\n\n` +
'checksum-payload'
)
}
async function makeTempDir() {
const directory = await mkdtemp(path.join(os.tmpdir(), 'motrix-appimage-'))
tempDirs.push(directory)
return directory
}