Path: blob/main/src/vs/platform/backup/electron-main/backupMainService.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 { createHash } from 'crypto';6import { isEqual } from '../../../base/common/extpath.js';7import { Schemas } from '../../../base/common/network.js';8import { join } from '../../../base/common/path.js';9import { isLinux } from '../../../base/common/platform.js';10import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js';11import { Promises, RimRafMode } from '../../../base/node/pfs.js';12import { IBackupMainService } from './backup.js';13import { ISerializedBackupWorkspaces, IEmptyWindowBackupInfo, isEmptyWindowBackupInfo, deserializeWorkspaceInfos, deserializeFolderInfos, ISerializedWorkspaceBackupInfo, ISerializedFolderBackupInfo, ISerializedEmptyWindowBackupInfo } from '../node/backup.js';14import { IConfigurationService } from '../../configuration/common/configuration.js';15import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js';16import { IStateService } from '../../state/node/state.js';17import { HotExitConfiguration, IFilesConfiguration } from '../../files/common/files.js';18import { ILogService } from '../../log/common/log.js';19import { IFolderBackupInfo, isFolderBackupInfo, IWorkspaceBackupInfo } from '../common/backup.js';20import { isWorkspaceIdentifier } from '../../workspace/common/workspace.js';21import { createEmptyWorkspaceIdentifier } from '../../workspaces/node/workspaces.js';2223export class BackupMainService implements IBackupMainService {2425declare readonly _serviceBrand: undefined;2627private static readonly backupWorkspacesMetadataStorageKey = 'backupWorkspaces';2829protected backupHome: string;3031private workspaces: IWorkspaceBackupInfo[] = [];32private folders: IFolderBackupInfo[] = [];33private emptyWindows: IEmptyWindowBackupInfo[] = [];3435// Comparers for paths and resources that will36// - ignore path casing on Windows/macOS37// - respect path casing on Linux38private readonly backupUriComparer = extUriBiasedIgnorePathCase;39private readonly backupPathComparer = { isEqual: (pathA: string, pathB: string) => isEqual(pathA, pathB, !isLinux) };4041constructor(42@IEnvironmentMainService environmentMainService: IEnvironmentMainService,43@IConfigurationService private readonly configurationService: IConfigurationService,44@ILogService private readonly logService: ILogService,45@IStateService private readonly stateService: IStateService46) {47this.backupHome = environmentMainService.backupHome;48}4950async initialize(): Promise<void> {5152// read backup workspaces53const serializedBackupWorkspaces = this.stateService.getItem<ISerializedBackupWorkspaces>(BackupMainService.backupWorkspacesMetadataStorageKey) ?? { workspaces: [], folders: [], emptyWindows: [] };5455// validate empty workspaces backups first56this.emptyWindows = await this.validateEmptyWorkspaces(serializedBackupWorkspaces.emptyWindows);5758// validate workspace backups59this.workspaces = await this.validateWorkspaces(deserializeWorkspaceInfos(serializedBackupWorkspaces));6061// validate folder backups62this.folders = await this.validateFolders(deserializeFolderInfos(serializedBackupWorkspaces));6364// store metadata in case some workspaces or folders have been removed65this.storeWorkspacesMetadata();66}6768protected getWorkspaceBackups(): IWorkspaceBackupInfo[] {69if (this.isHotExitOnExitAndWindowClose()) {70// Only non-folder windows are restored on main process launch when71// hot exit is configured as onExitAndWindowClose.72return [];73}7475return this.workspaces.slice(0); // return a copy76}7778protected getFolderBackups(): IFolderBackupInfo[] {79if (this.isHotExitOnExitAndWindowClose()) {80// Only non-folder windows are restored on main process launch when81// hot exit is configured as onExitAndWindowClose.82return [];83}8485return this.folders.slice(0); // return a copy86}8788isHotExitEnabled(): boolean {89return this.getHotExitConfig() !== HotExitConfiguration.OFF;90}9192private isHotExitOnExitAndWindowClose(): boolean {93return this.getHotExitConfig() === HotExitConfiguration.ON_EXIT_AND_WINDOW_CLOSE;94}9596private getHotExitConfig(): string {97const config = this.configurationService.getValue<IFilesConfiguration>();9899return config?.files?.hotExit || HotExitConfiguration.ON_EXIT;100}101102getEmptyWindowBackups(): IEmptyWindowBackupInfo[] {103return this.emptyWindows.slice(0); // return a copy104}105106registerWorkspaceBackup(workspaceInfo: IWorkspaceBackupInfo): string;107registerWorkspaceBackup(workspaceInfo: IWorkspaceBackupInfo, migrateFrom: string): Promise<string>;108registerWorkspaceBackup(workspaceInfo: IWorkspaceBackupInfo, migrateFrom?: string): string | Promise<string> {109if (!this.workspaces.some(workspace => workspaceInfo.workspace.id === workspace.workspace.id)) {110this.workspaces.push(workspaceInfo);111this.storeWorkspacesMetadata();112}113114const backupPath = join(this.backupHome, workspaceInfo.workspace.id);115116if (migrateFrom) {117return this.moveBackupFolder(backupPath, migrateFrom).then(() => backupPath);118}119120return backupPath;121}122123private async moveBackupFolder(backupPath: string, moveFromPath: string): Promise<void> {124125// Target exists: make sure to convert existing backups to empty window backups126if (await Promises.exists(backupPath)) {127await this.convertToEmptyWindowBackup(backupPath);128}129130// When we have data to migrate from, move it over to the target location131if (await Promises.exists(moveFromPath)) {132try {133await Promises.rename(moveFromPath, backupPath, false /* no retry */);134} catch (error) {135this.logService.error(`Backup: Could not move backup folder to new location: ${error.toString()}`);136}137}138}139140registerFolderBackup(folderInfo: IFolderBackupInfo): string {141if (!this.folders.some(folder => this.backupUriComparer.isEqual(folderInfo.folderUri, folder.folderUri))) {142this.folders.push(folderInfo);143this.storeWorkspacesMetadata();144}145146return join(this.backupHome, this.getFolderHash(folderInfo));147}148149registerEmptyWindowBackup(emptyWindowInfo: IEmptyWindowBackupInfo): string {150if (!this.emptyWindows.some(emptyWindow => !!emptyWindow.backupFolder && this.backupPathComparer.isEqual(emptyWindow.backupFolder, emptyWindowInfo.backupFolder))) {151this.emptyWindows.push(emptyWindowInfo);152this.storeWorkspacesMetadata();153}154155return join(this.backupHome, emptyWindowInfo.backupFolder);156}157158private async validateWorkspaces(rootWorkspaces: IWorkspaceBackupInfo[]): Promise<IWorkspaceBackupInfo[]> {159if (!Array.isArray(rootWorkspaces)) {160return [];161}162163const seenIds: Set<string> = new Set();164const result: IWorkspaceBackupInfo[] = [];165166// Validate Workspaces167for (const workspaceInfo of rootWorkspaces) {168const workspace = workspaceInfo.workspace;169if (!isWorkspaceIdentifier(workspace)) {170return []; // wrong format, skip all entries171}172173if (!seenIds.has(workspace.id)) {174seenIds.add(workspace.id);175176const backupPath = join(this.backupHome, workspace.id);177const hasBackups = await this.doHasBackups(backupPath);178179// If the workspace has no backups, ignore it180if (hasBackups) {181if (workspace.configPath.scheme !== Schemas.file || await Promises.exists(workspace.configPath.fsPath)) {182result.push(workspaceInfo);183} else {184// If the workspace has backups, but the target workspace is missing, convert backups to empty ones185await this.convertToEmptyWindowBackup(backupPath);186}187} else {188await this.deleteStaleBackup(backupPath);189}190}191}192193return result;194}195196private async validateFolders(folderWorkspaces: IFolderBackupInfo[]): Promise<IFolderBackupInfo[]> {197if (!Array.isArray(folderWorkspaces)) {198return [];199}200201const result: IFolderBackupInfo[] = [];202const seenIds: Set<string> = new Set();203for (const folderInfo of folderWorkspaces) {204const folderURI = folderInfo.folderUri;205const key = this.backupUriComparer.getComparisonKey(folderURI);206if (!seenIds.has(key)) {207seenIds.add(key);208209const backupPath = join(this.backupHome, this.getFolderHash(folderInfo));210const hasBackups = await this.doHasBackups(backupPath);211212// If the folder has no backups, ignore it213if (hasBackups) {214if (folderURI.scheme !== Schemas.file || await Promises.exists(folderURI.fsPath)) {215result.push(folderInfo);216} else {217// If the folder has backups, but the target workspace is missing, convert backups to empty ones218await this.convertToEmptyWindowBackup(backupPath);219}220} else {221await this.deleteStaleBackup(backupPath);222}223}224}225226return result;227}228229private async validateEmptyWorkspaces(emptyWorkspaces: IEmptyWindowBackupInfo[]): Promise<IEmptyWindowBackupInfo[]> {230if (!Array.isArray(emptyWorkspaces)) {231return [];232}233234const result: IEmptyWindowBackupInfo[] = [];235const seenIds: Set<string> = new Set();236237// Validate Empty Windows238for (const backupInfo of emptyWorkspaces) {239const backupFolder = backupInfo.backupFolder;240if (typeof backupFolder !== 'string') {241return [];242}243244if (!seenIds.has(backupFolder)) {245seenIds.add(backupFolder);246247const backupPath = join(this.backupHome, backupFolder);248if (await this.doHasBackups(backupPath)) {249result.push(backupInfo);250} else {251await this.deleteStaleBackup(backupPath);252}253}254}255256return result;257}258259private async deleteStaleBackup(backupPath: string): Promise<void> {260try {261await Promises.rm(backupPath, RimRafMode.MOVE);262} catch (error) {263this.logService.error(`Backup: Could not delete stale backup: ${error.toString()}`);264}265}266267private prepareNewEmptyWindowBackup(): IEmptyWindowBackupInfo {268269// We are asked to prepare a new empty window backup folder.270// Empty windows backup folders are derived from a workspace271// identifier, so we generate a new empty workspace identifier272// until we found a unique one.273274let emptyWorkspaceIdentifier = createEmptyWorkspaceIdentifier();275while (this.emptyWindows.some(emptyWindow => !!emptyWindow.backupFolder && this.backupPathComparer.isEqual(emptyWindow.backupFolder, emptyWorkspaceIdentifier.id))) {276emptyWorkspaceIdentifier = createEmptyWorkspaceIdentifier();277}278279return { backupFolder: emptyWorkspaceIdentifier.id };280}281282private async convertToEmptyWindowBackup(backupPath: string): Promise<boolean> {283const newEmptyWindowBackupInfo = this.prepareNewEmptyWindowBackup();284285// Rename backupPath to new empty window backup path286const newEmptyWindowBackupPath = join(this.backupHome, newEmptyWindowBackupInfo.backupFolder);287try {288await Promises.rename(backupPath, newEmptyWindowBackupPath, false /* no retry */);289} catch (error) {290this.logService.error(`Backup: Could not rename backup folder: ${error.toString()}`);291return false;292}293this.emptyWindows.push(newEmptyWindowBackupInfo);294295return true;296}297298async getDirtyWorkspaces(): Promise<Array<IWorkspaceBackupInfo | IFolderBackupInfo>> {299const dirtyWorkspaces: Array<IWorkspaceBackupInfo | IFolderBackupInfo> = [];300301// Workspaces with backups302for (const workspace of this.workspaces) {303if ((await this.hasBackups(workspace))) {304dirtyWorkspaces.push(workspace);305}306}307308// Folders with backups309for (const folder of this.folders) {310if ((await this.hasBackups(folder))) {311dirtyWorkspaces.push(folder);312}313}314315return dirtyWorkspaces;316}317318private hasBackups(backupLocation: IWorkspaceBackupInfo | IEmptyWindowBackupInfo | IFolderBackupInfo): Promise<boolean> {319let backupPath: string;320321// Empty322if (isEmptyWindowBackupInfo(backupLocation)) {323backupPath = join(this.backupHome, backupLocation.backupFolder);324}325326// Folder327else if (isFolderBackupInfo(backupLocation)) {328backupPath = join(this.backupHome, this.getFolderHash(backupLocation));329}330331// Workspace332else {333backupPath = join(this.backupHome, backupLocation.workspace.id);334}335336return this.doHasBackups(backupPath);337}338339private async doHasBackups(backupPath: string): Promise<boolean> {340try {341const backupSchemas = await Promises.readdir(backupPath);342343for (const backupSchema of backupSchemas) {344try {345const backupSchemaChildren = await Promises.readdir(join(backupPath, backupSchema));346if (backupSchemaChildren.length > 0) {347return true;348}349} catch (error) {350// invalid folder351}352}353} catch (error) {354// backup path does not exist355}356357return false;358}359360361private storeWorkspacesMetadata(): void {362const serializedBackupWorkspaces: ISerializedBackupWorkspaces = {363workspaces: this.workspaces.map(({ workspace, remoteAuthority }) => {364const serializedWorkspaceBackupInfo: ISerializedWorkspaceBackupInfo = {365id: workspace.id,366configURIPath: workspace.configPath.toString()367};368369if (remoteAuthority) {370serializedWorkspaceBackupInfo.remoteAuthority = remoteAuthority;371}372373return serializedWorkspaceBackupInfo;374}),375folders: this.folders.map(({ folderUri, remoteAuthority }) => {376const serializedFolderBackupInfo: ISerializedFolderBackupInfo =377{378folderUri: folderUri.toString()379};380381if (remoteAuthority) {382serializedFolderBackupInfo.remoteAuthority = remoteAuthority;383}384385return serializedFolderBackupInfo;386}),387emptyWindows: this.emptyWindows.map(({ backupFolder, remoteAuthority }) => {388const serializedEmptyWindowBackupInfo: ISerializedEmptyWindowBackupInfo = {389backupFolder390};391392if (remoteAuthority) {393serializedEmptyWindowBackupInfo.remoteAuthority = remoteAuthority;394}395396return serializedEmptyWindowBackupInfo;397})398};399400this.stateService.setItem(BackupMainService.backupWorkspacesMetadataStorageKey, serializedBackupWorkspaces);401}402403protected getFolderHash(folder: IFolderBackupInfo): string {404const folderUri = folder.folderUri;405406let key: string;407if (folderUri.scheme === Schemas.file) {408key = isLinux ? folderUri.fsPath : folderUri.fsPath.toLowerCase(); // for backward compatibility, use the fspath as key409} else {410key = folderUri.toString().toLowerCase();411}412413return createHash('md5').update(key).digest('hex'); // CodeQL [SM04514] Using MD5 to convert a file path to a fixed length414}415}416417418