From 7c7d696549b2285a12c6cf6d1ef435ecf7e8c87f Mon Sep 17 00:00:00 2001 From: Richard Herman Date: Thu, 22 Feb 2024 14:38:26 +0000 Subject: [PATCH] fix: extraction of @elgato/schemas Previously, completion would occur when the contents of the HTTP stream had been fully read, and not when the contents had been extract. This changes updates the flow so that the file is first downloaded, integrity checked, and then installed. There is also a fallback in place to re-install the previous version should the update fail. --- .gitignore | 3 +- src/common/path.ts | 41 +++++++++++++++- src/package-manager.ts | 104 ++++++++++++++++++++++++++++++++--------- 3 files changed, 123 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index bde75b9..3c1a7f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,9 @@ # Node.js node_modules/ -# Build output +# Build and temporary files bin/ +/.tmp/ # CLI /.cli.cache diff --git a/src/common/path.ts b/src/common/path.ts index 5ab9575..9cb52b0 100644 --- a/src/common/path.ts +++ b/src/common/path.ts @@ -1,4 +1,4 @@ -import { existsSync, lstatSync, readdirSync, readlinkSync, Stats } from "node:fs"; +import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readlinkSync, rmSync, Stats } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -55,6 +55,45 @@ export function isSafeBaseName(value: string): boolean { return !invalidCharacters.some((invalid) => value.includes(invalid)); } +/** + * Synchronously moves the {@link source} to the {@link dest} path. + * @param source Source path being moved. + * @param dest Destination where the {@link source} will be moved to. + * @param options Options that define the move. + */ +export function moveSync(source: string, dest: string, options?: MoveOptions): void { + if (!existsSync(source)) { + throw new Error("Source does not exist"); + } + + if (!lstatSync(source).isDirectory()) { + throw new Error("Source must be a directory"); + } + + if (existsSync(dest)) { + if (options?.overwrite) { + rmSync(dest, { recursive: true }); + } else { + throw new Error("Destination already exists"); + } + } + + // Ensure the new directory exists, copy the contents, and clean-up. + mkdirSync(dest, { recursive: true }); + cpSync(source, dest, { recursive: true }); + rmSync(source, { recursive: true }); +} + +/** + * Defines how a path will be relocated. + */ +type MoveOptions = { + /** + * When the destination path already exists, it will be overwritten. + */ + overwrite?: boolean; +}; + /** * Resolves the specified {@link path} relatives to the entry point. * @param path Path being resolved. diff --git a/src/package-manager.ts b/src/package-manager.ts index 73a687c..ddae909 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -1,10 +1,11 @@ -import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; -import { join } from "node:path"; +import { createHash } from "node:crypto"; +import { createWriteStream, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; import { Readable } from "node:stream"; import semver from "semver"; import tar from "tar"; import { dependencies, version } from "../package.json"; -import { relative } from "./common/path"; +import { moveSync, relative } from "./common/path"; /** * Light-weight package manager that wraps npm, capable of updating locally-scoped installed packages. @@ -51,27 +52,39 @@ class PackageManager { * @param pkg Package to install. */ public async install(pkg: PackageMetadataVersion): Promise { - const res = await fetch(pkg.dist.tarball); - await new Promise((resolve, reject) => { - if (res.body === null) { - reject(`Failed to download package ${pkg.name} from ${pkg.dist.tarball}`); - return; - } - - // Clean the installation directory. - const cwd = relative(`../node_modules/${pkg.name}`); - if (existsSync(cwd)) { - rmSync(cwd, { recursive: true }); + // Download the package's tarball file to a temporary location. + const file = relative(`../.tmp/${pkg.dist.shasum}.tar.gz`); + mkdirSync(dirname(file), { recursive: true }); + + try { + await this.download(pkg, file); + + // Determine the package paths. + const installationPath = relative(`../node_modules/${pkg.name}`); + const tempPath = relative("../.tmp/@elgato/schemas/"); + + try { + // Move the current installed package, and unpack the new package to node_modules. + moveSync(installationPath, tempPath, { overwrite: true }); + mkdirSync(installationPath, { recursive: true }); + await tar.extract({ + file, + strip: 1, + cwd: installationPath + }); + } catch (err) { + // When something goes wrong, fallback to the previous package. + moveSync(tempPath, installationPath, { overwrite: true }); + throw err; + } finally { + // Cleanup the temporary cache. + if (existsSync(tempPath)) { + rmSync(tempPath, { recursive: true }); + } } - - mkdirSync(cwd, { recursive: true }); - - // Decompress the contents fo the installation directory. - const stream = Readable.fromWeb(res.body); - stream.on("close", () => resolve(true)); - stream.on("error", (err) => reject(err)); - stream.pipe(tar.extract({ strip: 1, cwd })); - }); + } finally { + rmSync(file); + } } /** @@ -112,6 +125,51 @@ class PackageManager { return (await res.json()) as PackageMetadata; } + /** + * Downloads the contents of the specified {@link pkg} to the {@link dest} file. + * @param pkg Package to download. + * @param dest File where the packed (i.e. the tarball file) packaged will be downloaded to. + */ + private async download(pkg: PackageMetadataVersion, dest: string): Promise { + if (existsSync(dest)) { + throw new Error(`File path already exists: ${dest}`); + } + + const res = await fetch(pkg.dist.tarball); + if (res.body === null) { + throw new Error(`Failed to download package ${pkg.name} from ${pkg.dist.tarball}`); + } + + // Create a hash to validate the download. + const fileStream = createWriteStream(dest, { encoding: "utf-8" }); + const body = Readable.fromWeb(res.body); + const hash = createHash("sha1"); + + return new Promise((resolve, reject) => { + fileStream.on("open", () => { + // Read the contents of the body into both the file stream, and the hash in parallel. + body + .on("data", (data) => { + hash.update(data); + fileStream.write(data); + }) + .on("error", reject) + .on("close", () => { + fileStream.close(() => { + // Validate the shasum. + const shasum = hash.digest("hex"); + if (shasum !== pkg.dist.shasum) { + rmSync(dest); + reject(`Failed to download package ${pkg.name} from ${pkg.dist.tarball}: shasum mismatch`); + } + + resolve(); + }); + }); + }); + }); + } + /** * Gets the latest version, from the package metadata, that satisfies the {@link range}. * @param pkg Package metadata whose versions should be checked.