Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/git/src/askpassManager.ts
5236 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as crypto from 'crypto';
7
import * as fs from 'fs';
8
import * as path from 'path';
9
import * as cp from 'child_process';
10
import { env, LogOutputChannel } from 'vscode';
11
12
/**
13
* Manages content-addressed copies of askpass scripts in a user-controlled folder.
14
*
15
* This solves the problem on Windows user/system setups where environment variables
16
* like GIT_ASKPASS point to scripts inside the VS Code installation directory, which
17
* changes on each update. By copying the scripts to a content-addressed location in
18
* user storage, the paths remain stable across updates (as long as the script contents
19
* don't change).
20
*
21
* This feature is only enabled on Windows user and system setups (not archive or portable)
22
* because those are the only configurations where the installation path changes on each update.
23
*
24
* Security considerations:
25
* - Scripts are placed in user-controlled storage (not TEMP to avoid TOCTOU attacks)
26
* - On Windows, ACLs are set to allow only the current user to modify the files
27
*/
28
29
/**
30
* Checks if the current VS Code installation is a Windows user or system setup.
31
* Returns false for archive, portable, or non-Windows installations.
32
*/
33
function isWindowsUserOrSystemSetup(): boolean {
34
if (process.platform !== 'win32') {
35
return false;
36
}
37
38
try {
39
const productJsonPath = path.join(env.appRoot, 'product.json');
40
const productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8'));
41
const target = productJson.target as string | undefined;
42
43
// Target is 'user' or 'system' for Inno Setup installations.
44
// Archive and portable builds don't have a target property.
45
return target === 'user' || target === 'system';
46
} catch {
47
// If we can't read product.json, assume not applicable
48
return false;
49
}
50
}
51
52
interface SourceAskpassPaths {
53
askpass: string;
54
askpassMain: string;
55
sshAskpass: string;
56
askpassEmpty: string;
57
sshAskpassEmpty: string;
58
}
59
60
/**
61
* Computes a SHA-256 hash of the combined contents of all askpass-related files.
62
* This hash is used to create content-addressed directories.
63
*/
64
function computeContentHash(sourcePaths: SourceAskpassPaths): string {
65
const hash = crypto.createHash('sha256');
66
67
// Hash all source files in a deterministic order
68
const files = [
69
sourcePaths.askpass,
70
sourcePaths.askpassMain,
71
sourcePaths.sshAskpass,
72
sourcePaths.askpassEmpty,
73
sourcePaths.sshAskpassEmpty,
74
];
75
76
for (const file of files) {
77
const content = fs.readFileSync(file);
78
hash.update(content);
79
// Include filename in hash to ensure different files with same content produce different hash
80
hash.update(path.basename(file));
81
}
82
83
return hash.digest('hex').substring(0, 16);
84
}
85
86
/**
87
* Sets restrictive file permissions on Windows using icacls.
88
* Grants full control only to the current user and removes inherited permissions.
89
*/
90
async function setWindowsPermissions(filePath: string, logger: LogOutputChannel): Promise<void> {
91
const username = process.env['USERNAME'];
92
if (!username) {
93
logger.warn(`[askpassManager] Cannot set Windows permissions: USERNAME not set`);
94
return;
95
}
96
97
return new Promise<void>((resolve) => {
98
// icacls <file> /inheritance:r /grant:r "<username>:F"
99
// /inheritance:r - Remove all inherited permissions
100
// /grant:r - Replace (not add) permissions, giving Full control to user
101
const args = [filePath, '/inheritance:r', '/grant:r', `${username}:F`];
102
103
cp.execFile('icacls', args, (error, _stdout, stderr) => {
104
if (error) {
105
logger.warn(`[askpassManager] Failed to set permissions on ${filePath}: ${error.message}`);
106
if (stderr) {
107
logger.warn(`[askpassManager] icacls stderr: ${stderr}`);
108
}
109
} else {
110
logger.trace(`[askpassManager] Set permissions on ${filePath}`);
111
}
112
resolve();
113
});
114
});
115
}
116
117
/**
118
* Copies a file to the destination, creating parent directories as needed.
119
* Sets restrictive permissions on the copied file.
120
*/
121
async function copyFileSecure(
122
source: string,
123
dest: string,
124
logger: LogOutputChannel
125
): Promise<void> {
126
const content = await fs.promises.readFile(source);
127
await fs.promises.writeFile(dest, content);
128
await setWindowsPermissions(dest, logger);
129
}
130
131
/**
132
* Updates the modification time of a directory to mark it as recently used.
133
*/
134
async function updateDirectoryMtime(dirPath: string, logger: LogOutputChannel): Promise<void> {
135
try {
136
const now = new Date();
137
await fs.promises.utimes(dirPath, now, now);
138
logger.trace(`[askpassManager] Updated mtime for ${dirPath}`);
139
} catch (err) {
140
logger.warn(`[askpassManager] Failed to update mtime for ${dirPath}: ${err}`);
141
}
142
}
143
144
/**
145
* Garbage collects old content-addressed askpass directories that haven't been used in 7 days.
146
* This prevents accumulation of old versions when VS Code updates.
147
*/
148
async function garbageCollectOldDirectories(
149
askpassBaseDir: string,
150
currentHash: string,
151
logger: LogOutputChannel
152
): Promise<void> {
153
try {
154
// Check if the askpass base directory exists
155
try {
156
await fs.promises.access(askpassBaseDir);
157
} catch {
158
// Directory doesn't exist, nothing to clean
159
return;
160
}
161
162
const entries = await fs.promises.readdir(askpassBaseDir);
163
const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
164
165
for (const entry of entries) {
166
// Skip the current content-addressed directory
167
if (entry === currentHash) {
168
continue;
169
}
170
171
const entryPath = path.join(askpassBaseDir, entry);
172
173
try {
174
const stat = await fs.promises.stat(entryPath);
175
176
// Only process directories
177
if (!stat.isDirectory()) {
178
continue;
179
}
180
181
// Check if the directory hasn't been used in 7 days
182
if (stat.mtime.getTime() < sevenDaysAgo) {
183
logger.info(`[askpassManager] Removing old askpass directory: ${entryPath} (last used: ${stat.mtime.toISOString()})`);
184
185
// Remove the directory and all its contents
186
await fs.promises.rm(entryPath, { recursive: true, force: true });
187
} else {
188
logger.trace(`[askpassManager] Keeping askpass directory: ${entryPath} (last used: ${stat.mtime.toISOString()})`);
189
}
190
} catch (err) {
191
logger.warn(`[askpassManager] Failed to process/remove directory ${entryPath}: ${err}`);
192
}
193
}
194
} catch (err) {
195
logger.warn(`[askpassManager] Failed to garbage collect old directories: ${err}`);
196
}
197
}
198
199
export interface AskpassPaths {
200
readonly askpass: string;
201
readonly askpassMain: string;
202
readonly sshAskpass: string;
203
readonly askpassEmpty: string;
204
readonly sshAskpassEmpty: string;
205
}
206
207
/**
208
* Ensures that content-addressed copies of askpass scripts exist in user storage.
209
* Returns the paths to the content-addressed copies.
210
*
211
* @param sourceDir The directory containing the original askpass scripts (__dirname)
212
* @param storageDir The user-controlled storage directory (context.storageUri.fsPath)
213
* @param logger Logger for diagnostic output
214
*/
215
export async function ensureAskpassScripts(
216
sourceDir: string,
217
storageDir: string,
218
logger: LogOutputChannel
219
): Promise<AskpassPaths> {
220
const sourcePaths: SourceAskpassPaths = {
221
askpass: path.join(sourceDir, 'askpass.sh'),
222
askpassMain: path.join(sourceDir, 'askpass-main.js'),
223
sshAskpass: path.join(sourceDir, 'ssh-askpass.sh'),
224
askpassEmpty: path.join(sourceDir, 'askpass-empty.sh'),
225
sshAskpassEmpty: path.join(sourceDir, 'ssh-askpass-empty.sh'),
226
};
227
228
// Compute content hash
229
const contentHash = computeContentHash(sourcePaths);
230
logger.trace(`[askpassManager] Content hash: ${contentHash}`);
231
232
// Create content-addressed directory
233
const askpassBaseDir = path.join(storageDir, 'askpass');
234
const askpassDir = path.join(askpassBaseDir, contentHash);
235
236
const destPaths: AskpassPaths = {
237
askpass: path.join(askpassDir, 'askpass.sh'),
238
askpassMain: path.join(askpassDir, 'askpass-main.js'),
239
sshAskpass: path.join(askpassDir, 'ssh-askpass.sh'),
240
askpassEmpty: path.join(askpassDir, 'askpass-empty.sh'),
241
sshAskpassEmpty: path.join(askpassDir, 'ssh-askpass-empty.sh'),
242
};
243
244
// Check if already exists (fast path for subsequent activations)
245
try {
246
const stat = await fs.promises.stat(destPaths.askpass);
247
if (stat.isFile()) {
248
logger.trace(`[askpassManager] Using existing content-addressed askpass at ${askpassDir}`);
249
250
// Update mtime to mark this directory as recently used
251
await updateDirectoryMtime(askpassDir, logger);
252
253
return destPaths;
254
}
255
} catch {
256
// Directory doesn't exist, create it
257
}
258
259
logger.info(`[askpassManager] Creating content-addressed askpass scripts at ${askpassDir}`);
260
261
// Create directory and set Windows ACLs
262
await fs.promises.mkdir(askpassDir, { recursive: true });
263
await setWindowsPermissions(askpassDir, logger);
264
265
// Copy all files
266
await Promise.all([
267
copyFileSecure(sourcePaths.askpass, destPaths.askpass, logger),
268
copyFileSecure(sourcePaths.askpassMain, destPaths.askpassMain, logger),
269
copyFileSecure(sourcePaths.sshAskpass, destPaths.sshAskpass, logger),
270
copyFileSecure(sourcePaths.askpassEmpty, destPaths.askpassEmpty, logger),
271
copyFileSecure(sourcePaths.sshAskpassEmpty, destPaths.sshAskpassEmpty, logger),
272
]);
273
274
logger.info(`[askpassManager] Successfully created content-addressed askpass scripts`);
275
276
// Update mtime to mark this directory as recently used
277
await updateDirectoryMtime(askpassDir, logger);
278
279
// Garbage collect old directories
280
await garbageCollectOldDirectories(askpassBaseDir, contentHash, logger);
281
282
return destPaths;
283
}
284
285
/**
286
* Returns the askpass script paths. Uses content-addressed copies
287
* on Windows user/system setups (to keep paths stable across updates),
288
* otherwise returns paths relative to the source directory.
289
*/
290
export async function getAskpassPaths(
291
sourceDir: string,
292
storagePath: string | undefined,
293
logger: LogOutputChannel
294
): Promise<AskpassPaths> {
295
// Try content-addressed paths on Windows user/system setups
296
if (storagePath && isWindowsUserOrSystemSetup()) {
297
try {
298
return await ensureAskpassScripts(sourceDir, storagePath, logger);
299
} catch (err) {
300
logger.error(`[askpassManager] Failed to create content-addressed askpass scripts: ${err}`);
301
}
302
}
303
304
// Fallback to source directory paths (for development or non-Windows setups)
305
return {
306
askpass: path.join(sourceDir, 'askpass.sh'),
307
askpassMain: path.join(sourceDir, 'askpass-main.js'),
308
sshAskpass: path.join(sourceDir, 'ssh-askpass.sh'),
309
askpassEmpty: path.join(sourceDir, 'askpass-empty.sh'),
310
sshAskpassEmpty: path.join(sourceDir, 'ssh-askpass-empty.sh'),
311
};
312
}
313
314