mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-04 21:37:45 +00:00
* nodejs: add tests to library * new tests * remove message splitting * fix race * docs * replace extract-zip with yauzl that it wraps (high severity issue) * fix ci tests * skip server tests on macos and win unless SMP_SERVER is set * fix win test * fix win test --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com>
243 lines
9.8 KiB
TypeScript
243 lines
9.8 KiB
TypeScript
import * as crypto from "crypto"
|
|
import * as fs from "fs"
|
|
import * as http from "http"
|
|
import * as https from "https"
|
|
import * as os from "os"
|
|
import * as path from "path"
|
|
import {pipeline} from "stream/promises"
|
|
import * as yauzl from "yauzl"
|
|
import type {Backend} from "./core"
|
|
import {ADDON_RELEASE, LIBS_RELEASE, SHA256} from "./release"
|
|
|
|
const GITHUB_REPO = "simplex-chat/simplex-chat-libs"
|
|
const REQUEST_TIMEOUT_MS = 60_000
|
|
export const MAX_REDIRECTS = 5
|
|
const PLATFORMS: {[platform: string]: {name: string, lib: string} | undefined} = {
|
|
linux: {name: "linux", lib: "libsimplex.so"},
|
|
darwin: {name: "macos", lib: "libsimplex.dylib"},
|
|
win32: {name: "windows", lib: "libsimplex.dll"},
|
|
}
|
|
const ARCHS: {[arch: string]: string | undefined} = {x64: "x86_64", arm64: "aarch64"}
|
|
const SUPPORTED = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "macos-aarch64", "windows-x86_64"]
|
|
const S_IFMT = 0o170000
|
|
const S_IFLNK = 0o120000
|
|
|
|
function unsupported(platform: string, arch: string): Error {
|
|
return new Error(`Unsupported platform: ${platform}/${arch}; supported: ${SUPPORTED.join(", ")}`)
|
|
}
|
|
|
|
export function platformTag(platform: string, arch: string): string {
|
|
const tag = `${PLATFORMS[platform]?.name}-${ARCHS[arch]}`
|
|
if (!SUPPORTED.includes(tag)) throw unsupported(platform, arch)
|
|
return tag
|
|
}
|
|
|
|
function libName(platform: string = process.platform, arch: string = process.arch): string {
|
|
const lib = PLATFORMS[platform]?.lib
|
|
if (!lib) throw unsupported(platform, arch)
|
|
return lib
|
|
}
|
|
|
|
export function cacheRoot(platform: string, env: NodeJS.ProcessEnv, home: string = os.homedir()): string {
|
|
if (platform === "darwin") return path.join(home, "Library", "Caches", "simplex-chat")
|
|
if (platform === "win32") {
|
|
if (!env.LOCALAPPDATA) throw new Error("LOCALAPPDATA is not set")
|
|
return path.join(env.LOCALAPPDATA, "simplex-chat")
|
|
}
|
|
return path.join(env.XDG_CACHE_HOME || path.join(home, ".cache"), "simplex-chat")
|
|
}
|
|
|
|
export function libsAsset(backend: Backend, tag: string): string {
|
|
const suffix = backend === "postgres" ? "-postgres" : ""
|
|
return `simplex-chat-libs-${tag}${suffix}.zip`
|
|
}
|
|
|
|
export function addonAsset(tag: string): string {
|
|
return `simplex-chat-nodejs-${tag}.node`
|
|
}
|
|
|
|
export function releaseUrl(release: string, asset: string): string {
|
|
return `https://github.com/${GITHUB_REPO}/releases/download/${release}/${asset}`
|
|
}
|
|
|
|
function pinnedSha256(asset: string): string {
|
|
const sha256 = SHA256[asset]
|
|
if (!sha256) throw new Error(`no SHA-256 is pinned for ${asset}`)
|
|
return sha256
|
|
}
|
|
|
|
export function libPath(dir: string): string {
|
|
return path.join(dir, libName())
|
|
}
|
|
|
|
export async function resolveLibsDir(
|
|
backend: Backend,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
platform: string = process.platform,
|
|
arch: string = process.arch
|
|
): Promise<string> {
|
|
const lib = libName(platform, arch)
|
|
if (env.SIMPLEX_LIBS_DIR) {
|
|
const dir = path.resolve(env.SIMPLEX_LIBS_DIR)
|
|
if (!fs.existsSync(path.join(dir, lib))) throw new Error(`SIMPLEX_LIBS_DIR has no ${lib}: ${dir}`)
|
|
return dir
|
|
}
|
|
const tag = platformTag(platform, arch)
|
|
if (backend === "postgres" && tag !== "linux-x86_64") {
|
|
throw new Error(`postgres backend is only supported on linux-x86_64; current platform is ${tag}`)
|
|
}
|
|
const target = path.resolve(cacheRoot(platform, env), LIBS_RELEASE, backend)
|
|
if (!fs.existsSync(path.join(target, lib))) {
|
|
const asset = libsAsset(backend, tag)
|
|
await installLibs(releaseUrl(LIBS_RELEASE, asset), pinnedSha256(asset), target, lib)
|
|
}
|
|
return target
|
|
}
|
|
|
|
export async function resolveAddonPath(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
platform: string = process.platform,
|
|
arch: string = process.arch
|
|
): Promise<string> {
|
|
if (env.SIMPLEX_ADDON_PATH) {
|
|
const file = path.resolve(env.SIMPLEX_ADDON_PATH)
|
|
if (!fs.existsSync(file)) throw new Error(`SIMPLEX_ADDON_PATH does not exist: ${file}`)
|
|
return file
|
|
}
|
|
if (!ADDON_RELEASE) throw new Error("no prebuilt Node.js add-on is pinned in this version; set SIMPLEX_ADDON_PATH")
|
|
const asset = addonAsset(platformTag(platform, arch))
|
|
const target = path.resolve(cacheRoot(platform, env), ADDON_RELEASE, asset)
|
|
if (!fs.existsSync(target)) await installAddon(releaseUrl(ADDON_RELEASE, asset), pinnedSha256(asset), target)
|
|
return target
|
|
}
|
|
|
|
export function installLibs(url: string, sha256: string, target: string, lib: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<void> {
|
|
return inTempDir(target, async tmp => {
|
|
process.stderr.write(`Downloading libsimplex from ${url} ...\n`)
|
|
const zipPath = path.join(tmp, "libs.zip")
|
|
await download(url, zipPath, timeoutMs)
|
|
await verifySha256(zipPath, sha256, url)
|
|
await unzip(zipPath, tmp)
|
|
const extracted = path.join(tmp, "libs")
|
|
if (!fs.existsSync(path.join(extracted, lib))) throw new Error(`libs/${lib} missing from ${url}`)
|
|
await renameInto(extracted, target, path.join(target, lib))
|
|
})
|
|
}
|
|
|
|
export function installAddon(url: string, sha256: string, target: string, timeoutMs: number = REQUEST_TIMEOUT_MS): Promise<void> {
|
|
return inTempDir(target, async tmp => {
|
|
process.stderr.write(`Downloading the Node.js add-on from ${url} ...\n`)
|
|
const file = path.join(tmp, path.basename(target))
|
|
await download(url, file, timeoutMs)
|
|
await verifySha256(file, sha256, url)
|
|
await renameInto(file, target, target)
|
|
})
|
|
}
|
|
|
|
function unzip(zipPath: string, dir: string): Promise<void> {
|
|
const root = path.resolve(dir)
|
|
return new Promise((resolve, reject) => {
|
|
yauzl.open(zipPath, {lazyEntries: true}, (err, zip) => {
|
|
if (err || !zip) return reject(err ?? new Error(`cannot open ${zipPath}`))
|
|
let failed = false
|
|
const fail = (e: unknown) => {
|
|
if (failed) return
|
|
failed = true
|
|
zip.close()
|
|
reject(e)
|
|
}
|
|
zip.on("error", e => {
|
|
failed = true
|
|
reject(e)
|
|
})
|
|
zip.on("end", () => resolve())
|
|
zip.on("entry", (entry: yauzl.Entry) => {
|
|
const dest = path.resolve(root, entry.fileName)
|
|
if (!dest.startsWith(root + path.sep)) return fail(new Error(`zip entry outside the target folder: ${entry.fileName}`))
|
|
const mode = entry.externalFileAttributes >>> 16
|
|
if ((mode & S_IFMT) === S_IFLNK) return fail(new Error(`zip entry is a symlink: ${entry.fileName}`))
|
|
if (entry.fileName.endsWith("/")) {
|
|
fs.promises.mkdir(dest, {recursive: true}).then(() => zip.readEntry(), fail)
|
|
return
|
|
}
|
|
zip.openReadStream(entry, (e, stream) => {
|
|
if (e || !stream) return fail(e ?? new Error(`cannot read ${entry.fileName}`))
|
|
fs.promises.mkdir(path.dirname(dest), {recursive: true})
|
|
.then(() => pipeline(stream, fs.createWriteStream(dest, {mode: (mode & 0o777) || 0o644})))
|
|
.then(() => zip.readEntry(), fail)
|
|
})
|
|
})
|
|
zip.readEntry()
|
|
})
|
|
})
|
|
}
|
|
|
|
async function verifySha256(file: string, expected: string, url: string): Promise<void> {
|
|
const hash = crypto.createHash("sha256")
|
|
for await (const chunk of fs.createReadStream(file)) hash.update(chunk)
|
|
const actual = hash.digest("hex")
|
|
if (actual !== expected) throw new Error(`SHA-256 of ${url} is ${actual}, expected ${expected}`)
|
|
}
|
|
|
|
// The temp dir is next to the target, so the final rename stays on one filesystem.
|
|
async function inTempDir(target: string, install: (tmp: string) => Promise<void>): Promise<void> {
|
|
const parent = path.dirname(target)
|
|
await fs.promises.mkdir(parent, {recursive: true})
|
|
const tmp = await fs.promises.mkdtemp(path.join(parent, ".download-"))
|
|
try {
|
|
await install(tmp)
|
|
} finally {
|
|
await fs.promises.rm(tmp, {recursive: true, force: true})
|
|
}
|
|
}
|
|
|
|
async function renameInto(from: string, target: string, installed: string): Promise<void> {
|
|
try {
|
|
await fs.promises.rename(from, target)
|
|
} catch (e) {
|
|
// Another process installed the same version first; its files are identical.
|
|
// Windows reports renaming onto an existing directory or a loaded file as EPERM.
|
|
const code = (e as NodeJS.ErrnoException).code
|
|
const lost = (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") && fs.existsSync(target)
|
|
if (!lost) throw e
|
|
if (!fs.existsSync(installed)) {
|
|
throw new Error(`another process partially populated ${target} but ${path.basename(installed)} is missing; remove it manually and retry`)
|
|
}
|
|
}
|
|
}
|
|
|
|
function download(url: string, dest: string, timeoutMs: number, redirects = 0): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
const get = url.startsWith("https:") ? https.get : http.get
|
|
// Once a response arrives, request errors are ignored: a download settles via pipeline, which
|
|
// waits for the file to close, so tmp cleanup cannot fail with EBUSY on Windows.
|
|
let res: http.IncomingMessage | undefined
|
|
const req = get(url, {headers: {"User-Agent": "simplex-chat-nodejs"}, timeout: timeoutMs}, response => {
|
|
res = response
|
|
const status = response.statusCode ?? 0
|
|
const location = response.headers.location
|
|
if (status >= 300 && status < 400 && location) {
|
|
response.resume()
|
|
if (redirects >= MAX_REDIRECTS) return reject(new Error(`too many redirects downloading ${url}`))
|
|
let next: URL
|
|
try {
|
|
next = new URL(location, url)
|
|
} catch (e) {
|
|
return reject(e)
|
|
}
|
|
if (next.protocol !== new URL(url).protocol) return reject(new Error(`redirect from ${url} to ${next} changes protocol`))
|
|
download(next.toString(), dest, timeoutMs, redirects + 1).then(resolve, reject)
|
|
return
|
|
}
|
|
if (status !== 200) {
|
|
response.resume()
|
|
return reject(new Error(`HTTP ${status} downloading ${url}`))
|
|
}
|
|
pipeline(response, fs.createWriteStream(dest)).then(resolve, reject)
|
|
})
|
|
const abort = (err: Error) => (res ? res.destroy(err) : req.destroy(err))
|
|
req.on("timeout", () => abort(new Error(`timeout downloading ${url}`)))
|
|
req.on("error", e => { if (!res) reject(e) })
|
|
})
|
|
}
|