Skip to content

Commit

Permalink
feat: add length/hash validation for downloads
Browse files Browse the repository at this point in the history
Fixes microsoft#246 (possibly)
though it requires microsoft/vscode-update-server#167
in order to be fully functional. At minimum it validates the length of
the stream matches the content-length the CDN told us about, and it
will validate SHA256 checksums when the update server provides them.

(I looked at getting them other ways, but it's very roundabout; the
update server should just add them on all its headers.)
  • Loading branch information
connor4312 committed Nov 22, 2023
1 parent 91c7f9c commit 6a41b27
Show file tree
Hide file tree
Showing 3 changed files with 63 additions and 9 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

### 2.3.7 | 2022-11-23

- Remove detection for unsupported win32 builds
- Add length and hash validation for downloaded builds

### 2.3.6 | 2022-10-24

- Fix windows sometimes failing on EPERM in download (again)
Expand Down
36 changes: 27 additions & 9 deletions lib/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ import * as cp from 'child_process';
import * as fs from 'fs';
import { tmpdir } from 'os';
import * as path from 'path';
import { pipeline, Readable } from 'stream';
import * as semver from 'semver';
import { pipeline } from 'stream';
import { promisify } from 'util';
import { ConsoleReporter, ProgressReporter, ProgressReportStage } from './progress';
import * as request from './request';
import * as semver from 'semver';
import {
downloadDirToExecutablePath,
getInsidersVersionMetadata,
Expand All @@ -26,6 +26,7 @@ import {
onceWithoutRejections,
streamToBuffer,
systemDefaultPlatform,
validateStream,
} from './util';

const extensionRoot = process.cwd();
Expand Down Expand Up @@ -181,14 +182,21 @@ export interface DownloadOptions {
readonly timeout?: number;
}

interface IDownload {
stream: NodeJS.ReadableStream;
format: 'zip' | 'tgz';
sha256?: string;
length: number;
}

/**
* Download a copy of VS Code archive to `.vscode-test`.
*
* @param version The version of VS Code to download such as '1.32.0'. You can also use
* `'stable'` for downloading latest stable release.
* `'insiders'` for downloading latest Insiders.
*/
async function downloadVSCodeArchive(options: DownloadOptions) {
async function downloadVSCodeArchive(options: DownloadOptions): Promise<IDownload> {
if (!fs.existsSync(options.cachePath)) {
fs.mkdirSync(options.cachePath);
}
Expand All @@ -206,6 +214,7 @@ async function downloadVSCodeArchive(options: DownloadOptions) {
throw 'Failed to get VS Code archive location';
}

const contentSHA256 = res.headers['x-sha256'] as string | undefined;
res.destroy();

const download = await request.getStream(url, timeout);
Expand Down Expand Up @@ -248,7 +257,12 @@ async function downloadVSCodeArchive(options: DownloadOptions) {
download.destroy();
});

return { stream: download, format: isZip ? 'zip' : 'tgz' } as const;
return {
stream: download,
format: isZip ? 'zip' : 'tgz',
sha256: contentSHA256,
length: totalBytes,
};
}

/**
Expand All @@ -257,11 +271,11 @@ async function downloadVSCodeArchive(options: DownloadOptions) {
async function unzipVSCode(
reporter: ProgressReporter,
extractDir: string,
stream: Readable,
platform: DownloadPlatform,
format: 'zip' | 'tgz'
{ format, stream, length, sha256 }: IDownload
) {
const stagingFile = path.join(tmpdir(), `vscode-test-${Date.now()}.zip`);
const checksum = validateStream(stream, length, sha256);

if (format === 'zip') {
try {
Expand All @@ -274,6 +288,8 @@ async function unzipVSCode(
// Expand-Archive on my machine.
if (process.platform === 'win32') {
const [buffer, JSZip] = await Promise.all([streamToBuffer(stream), import('jszip')]);
await checksum;

const content = await JSZip.loadAsync(buffer);
// extract file with jszip
for (const filename of Object.keys(content.files)) {
Expand All @@ -294,6 +310,7 @@ async function unzipVSCode(
} else {
// darwin or *nix sync
await pipelineAsync(stream, fs.createWriteStream(stagingFile));
await checksum;
await spawnDecompressorChild('unzip', ['-q', stagingFile, '-d', extractDir]);
}
} finally {
Expand All @@ -308,10 +325,11 @@ async function unzipVSCode(
// The CLI is a singular binary that doesn't have a wrapper component to remove
const s = platform.includes('cli-') ? 0 : 1;
await spawnDecompressorChild('tar', ['-xzf', '-', `--strip-components=${s}`, '-C', extractDir], stream);
await checksum;
}
}

function spawnDecompressorChild(command: string, args: ReadonlyArray<string>, input?: Readable) {
function spawnDecompressorChild(command: string, args: ReadonlyArray<string>, input?: NodeJS.ReadableStream) {
return new Promise<void>((resolve, reject) => {
const child = cp.spawn(command, args, { stdio: 'pipe' });
if (input) {
Expand Down Expand Up @@ -415,7 +433,7 @@ export async function download(options: Partial<DownloadOptions> = {}): Promise<
try {
await fs.promises.rm(downloadedPath, { recursive: true, force: true });

const { stream, format } = await downloadVSCodeArchive({
const download = await downloadVSCodeArchive({
version,
platform,
cachePath,
Expand All @@ -424,7 +442,7 @@ export async function download(options: Partial<DownloadOptions> = {}): Promise<
});
// important! do not put anything async here, since unzipVSCode will need
// to start consuming the stream immediately.
await unzipVSCode(reporter, downloadedPath, stream, platform, format);
await unzipVSCode(reporter, downloadedPath, platform, download);

await fs.promises.writeFile(path.join(downloadedPath, COMPLETE_FILE_NAME), '');
reporter.report({ stage: ProgressReportStage.NewInstallComplete, downloadedPath });
Expand Down
31 changes: 31 additions & 0 deletions lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { URL } from 'url';
import { DownloadPlatform } from './download';
import * as request from './request';
import { TestOptions, getProfileArguments } from './runTest';
import { createHash } from 'crypto';

export let systemDefaultPlatform: DownloadPlatform;

Expand Down Expand Up @@ -199,6 +200,36 @@ export function isDefined<T>(arg: T | undefined | null): arg is T {
return arg != null;
}

/**
* Validates the stream data matches the given length and checksum, if any.
*
* Note: md5 is not ideal, but it's what we get from the CDN, and for the
* purposes of self-reported content verification is sufficient.
*/
export function validateStream(readable: NodeJS.ReadableStream, length: number, sha256?: string) {
let actualLen = 0;
const checksum = sha256 ? createHash('sha256') : undefined;
return new Promise<void>((resolve, reject) => {
readable.on('data', (chunk) => {
checksum?.update(chunk);
actualLen += chunk.length;
});
readable.on('error', reject);
readable.on('end', () => {
if (actualLen !== length) {
return reject(new Error(`Downloaded stream length ${actualLen} does not match expected length ${length}`));
}

const digest = checksum?.digest('hex');
if (digest && digest !== sha256) {
return reject(new Error(`Downloaded file checksum ${digest} does not match expected checksum ${sha256}`));
}

resolve();
});
});
}

/** Gets a Buffer from a Node.js stream */
export function streamToBuffer(readable: NodeJS.ReadableStream) {
return new Promise<Buffer>((resolve, reject) => {
Expand Down

0 comments on commit 6a41b27

Please sign in to comment.