|
| 1 | +import { createHash } from 'crypto'; |
| 2 | +import * as fs from 'fs'; |
| 3 | +import { homedir } from 'os'; |
| 4 | +import * as path from 'path'; |
| 5 | +import { lock } from 'proper-lockfile'; |
| 6 | +import { LegacyMigrationError, readLegacyCredential } from './legacyCredentials'; |
| 7 | +import type { IPublisher, IStore } from './store'; |
| 8 | +import { log, read } from './util'; |
| 9 | +import { validatePublisher } from './validation'; |
| 10 | + |
| 11 | +export interface ILegacyMigrationOptions { |
| 12 | + readonly serviceName?: string; |
| 13 | + readonly lockPath?: string; |
| 14 | + readonly platform?: NodeJS.Platform; |
| 15 | + readonly interactive?: boolean; |
| 16 | + readonly prompt?: (question: string) => Promise<string>; |
| 17 | + readonly readCredential?: (serviceName: string, publisherName: string) => Promise<IPublisher | undefined>; |
| 18 | +} |
| 19 | + |
| 20 | +// Decorate the native store so migration policy and write synchronization stay |
| 21 | +// separate from its ordinary credential operations. |
| 22 | +export class LegacyCredentialMigration implements IStore { |
| 23 | + static wrap(store: IStore, openStore: () => Promise<IStore>, options: ILegacyMigrationOptions = {}): IStore { |
| 24 | + const platform = options.platform ?? process.platform; |
| 25 | + return platform === 'win32' || platform === 'linux' |
| 26 | + ? new LegacyCredentialMigration(store, openStore, options) |
| 27 | + : store; |
| 28 | + } |
| 29 | + |
| 30 | + private readonly serviceName: string; |
| 31 | + private readonly platform: NodeJS.Platform; |
| 32 | + private readonly lockPath: string; |
| 33 | + private readonly interactive: boolean; |
| 34 | + private readonly prompt: (question: string) => Promise<string>; |
| 35 | + private readonly readCredential: (service: string, name: string) => Promise<IPublisher | undefined>; |
| 36 | + |
| 37 | + constructor( |
| 38 | + private store: IStore, |
| 39 | + private readonly openStore: () => Promise<IStore>, |
| 40 | + options: ILegacyMigrationOptions = {} |
| 41 | + ) { |
| 42 | + this.serviceName = options.serviceName ?? 'vscode-vsce'; |
| 43 | + this.platform = options.platform ?? process.platform; |
| 44 | + this.lockPath = options.lockPath ?? path.join(homedir(), '.vsce-keytar-migration', |
| 45 | + createHash('sha256').update(`${this.platform}:${this.serviceName}`).digest('hex')); |
| 46 | + // read() otherwise answers "y" in tests and non-interactive processes. |
| 47 | + this.interactive = options.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY && !process.env.VSCE_TESTS); |
| 48 | + this.prompt = options.prompt ?? read; |
| 49 | + this.readCredential = options.readCredential |
| 50 | + ?? ((service, name) => readLegacyCredential(service, name, { platform: this.platform })); |
| 51 | + } |
| 52 | + |
| 53 | + get size(): number { |
| 54 | + return this.store.size; |
| 55 | + } |
| 56 | + |
| 57 | + get(name: string): IPublisher | undefined { |
| 58 | + return this.findPublisher(this.store, name); |
| 59 | + } |
| 60 | + |
| 61 | + async add(publisher: IPublisher): Promise<void> { |
| 62 | + await this.withLock(() => this.store.add({ |
| 63 | + name: this.get(publisher.name)?.name ?? publisher.name, |
| 64 | + pat: publisher.pat, |
| 65 | + })); |
| 66 | + } |
| 67 | + |
| 68 | + async delete(name: string): Promise<void> { |
| 69 | + await this.withLock(() => this.store.delete(this.get(name)?.name ?? name)); |
| 70 | + } |
| 71 | + |
| 72 | + [Symbol.iterator](): Iterator<IPublisher> { |
| 73 | + return this.store[Symbol.iterator](); |
| 74 | + } |
| 75 | + |
| 76 | + async tryMigratePublisher(name: string): Promise<IPublisher | undefined> { |
| 77 | + validatePublisher(name); |
| 78 | + const existing = this.get(name); |
| 79 | + if (existing || !this.interactive || (this.platform !== 'win32' && this.platform !== 'linux')) { |
| 80 | + return existing; |
| 81 | + } |
| 82 | + |
| 83 | + try { |
| 84 | + const legacy = await this.readCredential(this.serviceName, name); |
| 85 | + if (!legacy) { |
| 86 | + return undefined; |
| 87 | + } |
| 88 | + if (!this.sameAccount(legacy.name, name) || !legacy.pat) { |
| 89 | + throw new LegacyMigrationError('The legacy credential reader returned an invalid credential.'); |
| 90 | + } |
| 91 | + const answer = await this.prompt( |
| 92 | + `A saved PAT for publisher '${name}' was found in the previous credential store. Copy it to the new store? [y/N] ` |
| 93 | + ); |
| 94 | + if (!/^y$/i.test(answer.trim())) { |
| 95 | + return undefined; |
| 96 | + } |
| 97 | + |
| 98 | + // Do not hold a cross-process lock while waiting for the user's answer. |
| 99 | + return await this.withLock(() => this.copyAndVerify({ name, pat: legacy.pat })); |
| 100 | + } catch (error) { |
| 101 | + if (!(error instanceof LegacyMigrationError)) { |
| 102 | + throw error; |
| 103 | + } |
| 104 | + log.warn(`${error.message} The previous credential was not changed. ` |
| 105 | + + (this.platform === 'linux' ? 'Legacy lookup requires secret-tool (libsecret-tools on Debian/Ubuntu) and an accessible desktop keyring. ' : '') |
| 106 | + + 'Enter a PAT to continue, or retry after resolving the credential-store problem.'); |
| 107 | + return undefined; |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + private async copyAndVerify(publisher: IPublisher): Promise<IPublisher> { |
| 112 | + let verified: IStore; |
| 113 | + try { |
| 114 | + const destination = await this.openStore(); |
| 115 | + const current = this.findPublisher(destination, publisher.name); |
| 116 | + if (current) { |
| 117 | + this.store = destination; |
| 118 | + return current; |
| 119 | + } |
| 120 | + await destination.add(publisher); |
| 121 | + verified = await this.openStore(); |
| 122 | + const saved = this.findPublisher(verified, publisher.name); |
| 123 | + if (saved?.pat !== publisher.pat) { |
| 124 | + throw new LegacyMigrationError('The copied PAT could not be verified.'); |
| 125 | + } |
| 126 | + } catch (error) { |
| 127 | + if (!(error instanceof Error)) { |
| 128 | + throw error; |
| 129 | + } |
| 130 | + // Native failures must not include secret values in CLI diagnostics. |
| 131 | + throw new LegacyMigrationError(`Could not copy and verify the previous PAT for publisher '${publisher.name}'.`); |
| 132 | + } |
| 133 | + this.store = verified; |
| 134 | + log.info(`Copied the saved PAT for publisher '${publisher.name}'. The previous credential was not changed.`); |
| 135 | + return publisher; |
| 136 | + } |
| 137 | + |
| 138 | + private sameAccount(a: string, b: string): boolean { |
| 139 | + return this.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; |
| 140 | + } |
| 141 | + |
| 142 | + private findPublisher(store: IStore, name: string): IPublisher | undefined { |
| 143 | + return [...store].find(publisher => this.sameAccount(publisher.name, name)); |
| 144 | + } |
| 145 | + |
| 146 | + private async withLock<T>(operation: () => Promise<T>): Promise<T> { |
| 147 | + let release: () => Promise<void>; |
| 148 | + try { |
| 149 | + await fs.promises.mkdir(this.lockPath, { recursive: true, mode: 0o700 }); |
| 150 | + release = await lock(this.lockPath, { |
| 151 | + stale: 120_000, |
| 152 | + update: 5_000, |
| 153 | + retries: { retries: 120, factor: 1, minTimeout: 250, maxTimeout: 250 }, |
| 154 | + }); |
| 155 | + } catch (error) { |
| 156 | + if (error instanceof Error && 'code' in error) { |
| 157 | + throw new LegacyMigrationError('Could not lock the credential store. Another vsce command may still be using it.'); |
| 158 | + } |
| 159 | + throw error; |
| 160 | + } |
| 161 | + try { |
| 162 | + return await operation(); |
| 163 | + } finally { |
| 164 | + await release(); |
| 165 | + } |
| 166 | + } |
| 167 | +} |
0 commit comments