Path: blob/main/src/vs/platform/extensionManagement/node/extensionDownloader.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Promises } from '../../../base/common/async.js';6import { getErrorMessage } from '../../../base/common/errors.js';7import { Disposable } from '../../../base/common/lifecycle.js';8import { Schemas } from '../../../base/common/network.js';9import { joinPath } from '../../../base/common/resources.js';10import * as semver from '../../../base/common/semver/semver.js';11import { URI } from '../../../base/common/uri.js';12import { generateUuid } from '../../../base/common/uuid.js';13import { Promises as FSPromises } from '../../../base/node/pfs.js';14import { buffer, CorruptZipMessage } from '../../../base/node/zip.js';15import { INativeEnvironmentService } from '../../environment/common/environment.js';16import { toExtensionManagementError } from '../common/abstractExtensionManagementService.js';17import { ExtensionManagementError, ExtensionManagementErrorCode, ExtensionSignatureVerificationCode, IExtensionGalleryService, IGalleryExtension, InstallOperation } from '../common/extensionManagement.js';18import { ExtensionKey, groupByExtension } from '../common/extensionManagementUtil.js';19import { fromExtractError } from './extensionManagementUtil.js';20import { IExtensionSignatureVerificationService } from './extensionSignatureVerificationService.js';21import { TargetPlatform } from '../../extensions/common/extensions.js';22import { FileOperationResult, IFileService, IFileStatWithMetadata, toFileOperationResult } from '../../files/common/files.js';23import { ILogService } from '../../log/common/log.js';24import { ITelemetryService } from '../../telemetry/common/telemetry.js';25import { IUriIdentityService } from '../../uriIdentity/common/uriIdentity.js';2627type RetryDownloadClassification = {28owner: 'sandy081';29comment: 'Event reporting the retry of downloading';30extensionId: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Extension Id' };31attempts: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Number of Attempts' };32};33type RetryDownloadEvent = {34extensionId: string;35attempts: number;36};3738export class ExtensionsDownloader extends Disposable {3940private static readonly SignatureArchiveExtension = '.sigzip';4142readonly extensionsDownloadDir: URI;43private readonly extensionsTrashDir: URI;44private readonly cache: number;45private readonly cleanUpPromise: Promise<void>;4647constructor(48@INativeEnvironmentService environmentService: INativeEnvironmentService,49@IFileService private readonly fileService: IFileService,50@IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService,51@IExtensionSignatureVerificationService private readonly extensionSignatureVerificationService: IExtensionSignatureVerificationService,52@ITelemetryService private readonly telemetryService: ITelemetryService,53@IUriIdentityService private readonly uriIdentityService: IUriIdentityService,54@ILogService private readonly logService: ILogService,55) {56super();57this.extensionsDownloadDir = environmentService.extensionsDownloadLocation;58this.extensionsTrashDir = uriIdentityService.extUri.joinPath(environmentService.extensionsDownloadLocation, `.trash`);59this.cache = 20; // Cache 20 downloaded VSIX files60this.cleanUpPromise = this.cleanUp();61}6263async download(extension: IGalleryExtension, operation: InstallOperation, verifySignature: boolean, clientTargetPlatform?: TargetPlatform): Promise<{ readonly location: URI; readonly verificationStatus: ExtensionSignatureVerificationCode | undefined }> {64await this.cleanUpPromise;6566const location = await this.downloadVSIX(extension, operation);6768if (!verifySignature) {69return { location, verificationStatus: undefined };70}7172if (!extension.isSigned) {73return { location, verificationStatus: ExtensionSignatureVerificationCode.NotSigned };74}7576let signatureArchiveLocation;77try {78signatureArchiveLocation = await this.downloadSignatureArchive(extension);79const verificationStatus = (await this.extensionSignatureVerificationService.verify(extension.identifier.id, extension.version, location.fsPath, signatureArchiveLocation.fsPath, clientTargetPlatform))?.code;80if (verificationStatus === ExtensionSignatureVerificationCode.PackageIsInvalidZip || verificationStatus === ExtensionSignatureVerificationCode.SignatureArchiveIsInvalidZip) {81try {82// Delete the downloaded vsix if VSIX or signature archive is invalid83await this.delete(location);84} catch (error) {85this.logService.error(error);86}87throw new ExtensionManagementError(CorruptZipMessage, ExtensionManagementErrorCode.CorruptZip);88}89return { location, verificationStatus };90} catch (error) {91try {92// Delete the downloaded VSIX if signature archive download fails93await this.delete(location);94} catch (error) {95this.logService.error(error);96}97throw error;98} finally {99if (signatureArchiveLocation) {100try {101// Delete signature archive always102await this.delete(signatureArchiveLocation);103} catch (error) {104this.logService.error(error);105}106}107}108}109110private async downloadVSIX(extension: IGalleryExtension, operation: InstallOperation): Promise<URI> {111try {112const location = joinPath(this.extensionsDownloadDir, this.getName(extension));113const attempts = await this.doDownload(extension, 'vsix', async () => {114await this.downloadFile(extension, location, location => this.extensionGalleryService.download(extension, location, operation));115try {116await this.validate(location.fsPath, 'extension/package.json');117} catch (error) {118try {119await this.fileService.del(location);120} catch (e) {121this.logService.warn(`Error while deleting: ${location.path}`, getErrorMessage(e));122}123throw error;124}125}, 2);126127if (attempts > 1) {128this.telemetryService.publicLog2<RetryDownloadEvent, RetryDownloadClassification>('extensiongallery:downloadvsix:retry', {129extensionId: extension.identifier.id,130attempts131});132}133134return location;135} catch (e) {136throw toExtensionManagementError(e, ExtensionManagementErrorCode.Download);137}138}139140private async downloadSignatureArchive(extension: IGalleryExtension): Promise<URI> {141try {142const location = joinPath(this.extensionsDownloadDir, `${this.getName(extension)}${ExtensionsDownloader.SignatureArchiveExtension}`);143const attempts = await this.doDownload(extension, 'sigzip', async () => {144await this.extensionGalleryService.downloadSignatureArchive(extension, location);145try {146await this.validate(location.fsPath, '.signature.p7s');147} catch (error) {148try {149await this.fileService.del(location);150} catch (e) {151this.logService.warn(`Error while deleting: ${location.path}`, getErrorMessage(e));152}153throw error;154}155}, 2);156157if (attempts > 1) {158this.telemetryService.publicLog2<RetryDownloadEvent, RetryDownloadClassification>('extensiongallery:downloadsigzip:retry', {159extensionId: extension.identifier.id,160attempts161});162}163164return location;165} catch (e) {166throw toExtensionManagementError(e, ExtensionManagementErrorCode.DownloadSignature);167}168}169170private async downloadFile(extension: IGalleryExtension, location: URI, downloadFn: (location: URI) => Promise<void>): Promise<void> {171// Do not download if exists172if (await this.fileService.exists(location)) {173return;174}175176// Download directly if locaiton is not file scheme177if (location.scheme !== Schemas.file) {178await downloadFn(location);179return;180}181182// Download to temporary location first only if file does not exist183const tempLocation = joinPath(this.extensionsDownloadDir, `.${generateUuid()}`);184try {185await downloadFn(tempLocation);186} catch (error) {187try {188await this.fileService.del(tempLocation);189} catch (e) { /* ignore */ }190throw error;191}192193try {194// Rename temp location to original195await FSPromises.rename(tempLocation.fsPath, location.fsPath, 2 * 60 * 1000 /* Retry for 2 minutes */);196} catch (error) {197try { await this.fileService.del(tempLocation); } catch (e) { /* ignore */ }198let exists = false;199try { exists = await this.fileService.exists(location); } catch (e) { /* ignore */ }200if (exists) {201this.logService.info(`Rename failed because the file was downloaded by another source. So ignoring renaming.`, extension.identifier.id, location.path);202} else {203this.logService.info(`Rename failed because of ${getErrorMessage(error)}. Deleted the file from downloaded location`, tempLocation.path);204throw error;205}206}207}208209private async doDownload(extension: IGalleryExtension, name: string, downloadFn: () => Promise<void>, retries: number): Promise<number> {210let attempts = 1;211while (true) {212try {213await downloadFn();214return attempts;215} catch (e) {216if (attempts++ > retries) {217throw e;218}219this.logService.warn(`Failed downloading ${name}. ${getErrorMessage(e)}. Retry again...`, extension.identifier.id);220}221}222}223224protected async validate(zipPath: string, filePath: string): Promise<void> {225try {226await buffer(zipPath, filePath);227} catch (e) {228throw fromExtractError(e);229}230}231232async delete(location: URI): Promise<void> {233await this.cleanUpPromise;234const trashRelativePath = this.uriIdentityService.extUri.relativePath(this.extensionsDownloadDir, location);235if (trashRelativePath) {236await this.fileService.move(location, this.uriIdentityService.extUri.joinPath(this.extensionsTrashDir, trashRelativePath), true);237} else {238await this.fileService.del(location);239}240}241242private async cleanUp(): Promise<void> {243try {244if (!(await this.fileService.exists(this.extensionsDownloadDir))) {245this.logService.trace('Extension VSIX downloads cache dir does not exist');246return;247}248249try {250await this.fileService.del(this.extensionsTrashDir, { recursive: true });251} catch (error) {252if (toFileOperationResult(error) !== FileOperationResult.FILE_NOT_FOUND) {253this.logService.error(error);254}255}256257const folderStat = await this.fileService.resolve(this.extensionsDownloadDir, { resolveMetadata: true });258if (folderStat.children) {259const toDelete: URI[] = [];260const vsixs: [ExtensionKey, IFileStatWithMetadata][] = [];261const signatureArchives: URI[] = [];262263for (const stat of folderStat.children) {264if (stat.name.endsWith(ExtensionsDownloader.SignatureArchiveExtension)) {265signatureArchives.push(stat.resource);266} else {267const extension = ExtensionKey.parse(stat.name);268if (extension) {269vsixs.push([extension, stat]);270}271}272}273274const byExtension = groupByExtension(vsixs, ([extension]) => extension);275const distinct: IFileStatWithMetadata[] = [];276for (const p of byExtension) {277p.sort((a, b) => semver.rcompare(a[0].version, b[0].version));278toDelete.push(...p.slice(1).map(e => e[1].resource)); // Delete outdated extensions279distinct.push(p[0][1]);280}281distinct.sort((a, b) => a.mtime - b.mtime); // sort by modified time282toDelete.push(...distinct.slice(0, Math.max(0, distinct.length - this.cache)).map(s => s.resource)); // Retain minimum cacheSize and delete the rest283toDelete.push(...signatureArchives); // Delete all signature archives284285await Promises.settled(toDelete.map(resource => {286this.logService.trace('Deleting from cache', resource.path);287return this.fileService.del(resource);288}));289}290} catch (e) {291this.logService.error(e);292}293}294295private getName(extension: IGalleryExtension): string {296return ExtensionKey.create(extension).toString().toLowerCase();297}298299}300301302