fix: move askpass scripts to stable location (#289400)

* fix: move askpass scripts to stable location

fixes #282020

* Update extensions/git/src/askpassManager.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* use global storage

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
João Moreno
2026-01-22 16:20:27 +01:00
committed by GitHub
parent 34a38fa3a1
commit e37fdc9118
3 changed files with 248 additions and 6 deletions

View File

@@ -0,0 +1,233 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import { env, LogOutputChannel } from 'vscode';
/**
* Manages content-addressed copies of askpass scripts in a user-controlled folder.
*
* This solves the problem on Windows user/system setups where environment variables
* like GIT_ASKPASS point to scripts inside the VS Code installation directory, which
* changes on each update. By copying the scripts to a content-addressed location in
* user storage, the paths remain stable across updates (as long as the script contents
* don't change).
*
* This feature is only enabled on Windows user and system setups (not archive or portable)
* because those are the only configurations where the installation path changes on each update.
*
* Security considerations:
* - Scripts are placed in user-controlled storage (not TEMP to avoid TOCTOU attacks)
* - On Windows, ACLs are set to allow only the current user to modify the files
*/
/**
* Checks if the current VS Code installation is a Windows user or system setup.
* Returns false for archive, portable, or non-Windows installations.
*/
function isWindowsUserOrSystemSetup(): boolean {
if (process.platform !== 'win32') {
return false;
}
try {
const productJsonPath = path.join(env.appRoot, 'product.json');
const productJson = JSON.parse(fs.readFileSync(productJsonPath, 'utf8'));
const target = productJson.target as string | undefined;
// Target is 'user' or 'system' for Inno Setup installations.
// Archive and portable builds don't have a target property.
return target === 'user' || target === 'system';
} catch {
// If we can't read product.json, assume not applicable
return false;
}
}
interface SourceAskpassPaths {
askpass: string;
askpassMain: string;
sshAskpass: string;
askpassEmpty: string;
sshAskpassEmpty: string;
}
/**
* Computes a SHA-256 hash of the combined contents of all askpass-related files.
* This hash is used to create content-addressed directories.
*/
function computeContentHash(sourcePaths: SourceAskpassPaths): string {
const hash = crypto.createHash('sha256');
// Hash all source files in a deterministic order
const files = [
sourcePaths.askpass,
sourcePaths.askpassMain,
sourcePaths.sshAskpass,
sourcePaths.askpassEmpty,
sourcePaths.sshAskpassEmpty,
];
for (const file of files) {
const content = fs.readFileSync(file);
hash.update(content);
// Include filename in hash to ensure different files with same content produce different hash
hash.update(path.basename(file));
}
return hash.digest('hex').substring(0, 16);
}
/**
* Sets restrictive file permissions on Windows using icacls.
* Grants full control only to the current user and removes inherited permissions.
*/
async function setWindowsPermissions(filePath: string, logger: LogOutputChannel): Promise<void> {
const username = process.env['USERNAME'];
if (!username) {
logger.warn(`[askpassManager] Cannot set Windows permissions: USERNAME not set`);
return;
}
return new Promise<void>((resolve) => {
// icacls <file> /inheritance:r /grant:r "<username>:F"
// /inheritance:r - Remove all inherited permissions
// /grant:r - Replace (not add) permissions, giving Full control to user
const args = [filePath, '/inheritance:r', '/grant:r', `${username}:F`];
cp.execFile('icacls', args, (error, _stdout, stderr) => {
if (error) {
logger.warn(`[askpassManager] Failed to set permissions on ${filePath}: ${error.message}`);
if (stderr) {
logger.warn(`[askpassManager] icacls stderr: ${stderr}`);
}
} else {
logger.trace(`[askpassManager] Set permissions on ${filePath}`);
}
resolve();
});
});
}
/**
* Copies a file to the destination, creating parent directories as needed.
* Sets restrictive permissions on the copied file.
*/
async function copyFileSecure(
source: string,
dest: string,
logger: LogOutputChannel
): Promise<void> {
const content = await fs.promises.readFile(source);
await fs.promises.writeFile(dest, content);
await setWindowsPermissions(dest, logger);
}
export interface AskpassPaths {
readonly askpass: string;
readonly askpassMain: string;
readonly sshAskpass: string;
readonly askpassEmpty: string;
readonly sshAskpassEmpty: string;
}
/**
* Ensures that content-addressed copies of askpass scripts exist in user storage.
* Returns the paths to the content-addressed copies.
*
* @param sourceDir The directory containing the original askpass scripts (__dirname)
* @param storageDir The user-controlled storage directory (context.storageUri.fsPath)
* @param logger Logger for diagnostic output
*/
async function ensureAskpassScripts(
sourceDir: string,
storageDir: string,
logger: LogOutputChannel
): Promise<AskpassPaths> {
const sourcePaths: SourceAskpassPaths = {
askpass: path.join(sourceDir, 'askpass.sh'),
askpassMain: path.join(sourceDir, 'askpass-main.js'),
sshAskpass: path.join(sourceDir, 'ssh-askpass.sh'),
askpassEmpty: path.join(sourceDir, 'askpass-empty.sh'),
sshAskpassEmpty: path.join(sourceDir, 'ssh-askpass-empty.sh'),
};
// Compute content hash
const contentHash = computeContentHash(sourcePaths);
logger.trace(`[askpassManager] Content hash: ${contentHash}`);
// Create content-addressed directory
const askpassDir = path.join(storageDir, 'askpass', contentHash);
const destPaths: AskpassPaths = {
askpass: path.join(askpassDir, 'askpass.sh'),
askpassMain: path.join(askpassDir, 'askpass-main.js'),
sshAskpass: path.join(askpassDir, 'ssh-askpass.sh'),
askpassEmpty: path.join(askpassDir, 'askpass-empty.sh'),
sshAskpassEmpty: path.join(askpassDir, 'ssh-askpass-empty.sh'),
};
// Check if already exists (fast path for subsequent activations)
try {
const stat = await fs.promises.stat(destPaths.askpass);
if (stat.isFile()) {
logger.trace(`[askpassManager] Using existing content-addressed askpass at ${askpassDir}`);
return destPaths;
}
} catch {
// Directory doesn't exist, create it
}
logger.info(`[askpassManager] Creating content-addressed askpass scripts at ${askpassDir}`);
// Create directory and set Windows ACLs
await fs.promises.mkdir(askpassDir, { recursive: true });
await setWindowsPermissions(askpassDir, logger);
// Copy all files
await Promise.all([
copyFileSecure(sourcePaths.askpass, destPaths.askpass, logger),
copyFileSecure(sourcePaths.askpassMain, destPaths.askpassMain, logger),
copyFileSecure(sourcePaths.sshAskpass, destPaths.sshAskpass, logger),
copyFileSecure(sourcePaths.askpassEmpty, destPaths.askpassEmpty, logger),
copyFileSecure(sourcePaths.sshAskpassEmpty, destPaths.sshAskpassEmpty, logger),
]);
logger.info(`[askpassManager] Successfully created content-addressed askpass scripts`);
return destPaths;
}
/**
* Returns the askpass script paths. Uses content-addressed copies
* on Windows user/system setups (to keep paths stable across updates),
* otherwise returns paths relative to the source directory.
*/
export async function getAskpassPaths(
sourceDir: string,
storagePath: string | undefined,
logger: LogOutputChannel
): Promise<AskpassPaths> {
// Try content-addressed paths on Windows user/system setups
if (storagePath && isWindowsUserOrSystemSetup()) {
try {
return await ensureAskpassScripts(sourceDir, storagePath, logger);
} catch (err) {
logger.error(`[askpassManager] Failed to create content-addressed askpass scripts: ${err}`);
}
}
// Fallback to source directory paths (for development or non-Windows setups)
return {
askpass: path.join(sourceDir, 'askpass.sh'),
askpassMain: path.join(sourceDir, 'askpass-main.js'),
sshAskpass: path.join(sourceDir, 'ssh-askpass.sh'),
askpassEmpty: path.join(sourceDir, 'askpass-empty.sh'),
sshAskpassEmpty: path.join(sourceDir, 'ssh-askpass-empty.sh'),
};
}