Skip to content
Open
183 changes: 161 additions & 22 deletions src/features/terminal/shells/cmd/cmdStartup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,116 @@ function getMainBatchFileContent(startupFile: string): string {
return content.join(lineSep);
}

/**
* Splits a CMD AutoRun string into individual commands delimited by '&' or '&&',
* properly respecting double-quoted segments.
*/
export function splitAutoRunCommands(autoRun: string): string[] {
const commands: string[] = [];
let current = '';
let inQuotes = false;

for (let i = 0; i < autoRun.length; i++) {
const char = autoRun[i];
if (char === '"') {
inQuotes = !inQuotes;
current += char;
} else if (char === '&' && !inQuotes) {
if (i + 1 < autoRun.length && autoRun[i + 1] === '&') {
i++;
}
const trimmed = current.trim();
if (trimmed.length > 0) {
commands.push(trimmed);
}
current = '';
} else {
current += char;
}
}

const trimmed = current.trim();
if (trimmed.length > 0) {
commands.push(trimmed);
}

return commands;
}

/**
* Returns the invocation string used in CMD AutoRun to call our startup batch file.
*/
export function getAutoRunCallCommand(mainBatchFile: string): string {
return `if exist "${mainBatchFile}" call "${mainBatchFile}"`;
}

function isOurAutoRunCommand(command: string, mainBatchFile: string, regMainBatchFile?: string): boolean {
return (
command.includes(mainBatchFile) ||
(regMainBatchFile !== undefined && regMainBatchFile.length > 0 && command.includes(regMainBatchFile))
);
}

/**
* Checks whether an AutoRun string already contains our startup batch invocation.
*/
export function hasAutoRunCommand(
autoRun: string | undefined,
mainBatchFile: string,
regMainBatchFile?: string,
): boolean {
if (!autoRun) {
return false;
}
const commands = splitAutoRunCommands(autoRun);
return commands.some((cmd) => isOurAutoRunCommand(cmd, mainBatchFile, regMainBatchFile));
}

/**
* Merges our invocation command into an existing AutoRun string without overwriting user content.
* If our command is already present, returns the existing string unchanged (idempotent).
*/
export function mergeAutoRunCommand(
existingAutoRun: string | undefined,
mainBatchFile: string,
regMainBatchFile?: string,
): string {
const ourCommand = getAutoRunCallCommand(mainBatchFile);
if (!existingAutoRun || existingAutoRun.trim().length === 0) {
return ourCommand;
}

if (hasAutoRunCommand(existingAutoRun, mainBatchFile, regMainBatchFile)) {
return existingAutoRun.trim();
}

return `${existingAutoRun.trim()} & ${ourCommand}`;
}

/**
* Removes only our startup batch invocation from an existing AutoRun string,
* preserving any user or other third-party commands.
* Returns undefined if no commands remain (meaning the key should be deleted/cleared).
*/
export function removeAutoRunCommand(
existingAutoRun: string | undefined,
mainBatchFile: string,
regMainBatchFile?: string,
): string | undefined {
if (!existingAutoRun || existingAutoRun.trim().length === 0) {
return undefined;
}

const commands = splitAutoRunCommands(existingAutoRun);
const remaining = commands.filter((cmd) => !isOurAutoRunCommand(cmd, mainBatchFile, regMainBatchFile));

if (remaining.length === 0) {
return undefined;
}

return remaining.join(' & ');
}

async function checkRegistryAutoRun(mainBatchFile: string, regMainBatchFile: string): Promise<boolean> {
if (!isWindows()) {
return false;
Expand Down Expand Up @@ -130,19 +240,26 @@ async function getExistingAutoRun(): Promise<string | undefined> {
return undefined;
}

async function setupRegistryAutoRun(mainBatchFile: string): Promise<boolean> {
async function setupRegistryAutoRun(mainBatchFile: string, regMainBatchFile?: string): Promise<boolean> {
if (!isWindows()) {
return false;
}

try {
// Set the registry key to call our main batch file
const existingAutoRun = await getExistingAutoRun();
if (hasAutoRunCommand(existingAutoRun, mainBatchFile, regMainBatchFile)) {
traceInfo(`SHELL: CMD AutoRun registry key already contains our main batch file`);
return true;
}

const newAutoRun = mergeAutoRunCommand(existingAutoRun, mainBatchFile, regMainBatchFile);
const escaped = newAutoRun.replace(/"/g, '\\"');
await execCommand(
`reg add "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /t REG_SZ /d "if exist \\"${mainBatchFile}\\" call \\"${mainBatchFile}\\"" /f`,
`reg add "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /t REG_SZ /d "${escaped}" /f`,
);

traceInfo(
`Set CMD AutoRun registry key [HKCU\\Software\\Microsoft\\Command Processor] to call: ${mainBatchFile}`,
`Set CMD AutoRun registry key [HKCU\\Software\\Microsoft\\Command Processor] to call: ${newAutoRun}`,
);
return true;
} catch (err) {
Expand Down Expand Up @@ -204,9 +321,6 @@ async function setupCmdStartup(cmdFiles: CmdFilePaths, key: string): Promise<boo
traceInfo(`SHELL: Created new CMD activation file at: ${cmdFiles.startupFile}\r\n${activationContent}`);
}

// Step 2: Get existing AutoRun content
const existingAutoRun = await getExistingAutoRun();

// Step 3: Create or update the main batch file
if (await fs.pathExists(cmdFiles.mainBatchFile)) {
const content = await fs.readFile(cmdFiles.mainBatchFile, 'utf8');
Expand All @@ -232,36 +346,61 @@ async function setupCmdStartup(cmdFiles: CmdFilePaths, key: string): Promise<boo
}

// Step 4: Setup registry AutoRun to call our main batch file
if (existingAutoRun?.includes(cmdFiles.regMainBatchFile) || existingAutoRun?.includes(cmdFiles.mainBatchFile)) {
traceInfo(`SHELL: CMD AutoRun registry key already contains our main batch file`);
const registrySetup = await setupRegistryAutoRun(cmdFiles.mainBatchFile, cmdFiles.regMainBatchFile);
return registrySetup;
} catch (err) {
traceVerbose(`Failed to setup CMD startup`, err);
return false;
}
}

async function removeRegistryAutoRun(mainBatchFile: string, regMainBatchFile?: string): Promise<boolean> {
if (!isWindows()) {
return false;
}

try {
const existingAutoRun = await getExistingAutoRun();
if (!existingAutoRun || !hasAutoRunCommand(existingAutoRun, mainBatchFile, regMainBatchFile)) {
return true;
}

const updatedAutoRun = removeAutoRunCommand(existingAutoRun, mainBatchFile, regMainBatchFile);
if (updatedAutoRun && updatedAutoRun.length > 0) {
const escaped = updatedAutoRun.replace(/"/g, '\\"');
await execCommand(
`reg add "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /t REG_SZ /d "${escaped}" /f`,
);
traceInfo(`Restored CMD AutoRun registry key to: ${updatedAutoRun}`);
} else {
const registrySetup = await setupRegistryAutoRun(cmdFiles.mainBatchFile);
return registrySetup;
await execCommand('reg delete "HKCU\\Software\\Microsoft\\Command Processor" /v AutoRun /f');
traceInfo('Removed CMD AutoRun registry key');
}
return true;
} catch (err) {
traceVerbose(`Failed to setup CMD startup`, err);
traceError('Failed to remove CMD AutoRun registry key [HKCU\\Software\\Microsoft\\Command Processor]', err);
return false;
}
}

async function removeCmdStartup(startupFile: string, key: string): Promise<boolean> {
// Note: We deliberately DO NOT remove the main batch file or registry AutoRun setting
// This allows other components to continue using the AutoRun functionality
if (await fs.pathExists(startupFile)) {
async function removeCmdStartup(cmdFiles: CmdFilePaths, key: string): Promise<boolean> {
if (await fs.pathExists(cmdFiles.startupFile)) {
try {
const content = await fs.readFile(startupFile, 'utf8');
const content = await fs.readFile(cmdFiles.startupFile, 'utf8');
if (hasStartupCode(content, regionStart, regionEnd, [key])) {
await fs.writeFile(startupFile, removeStartupCode(content, regionStart, regionEnd));
traceInfo(`Removed activation from CMD activation file at: ${startupFile}`);
await fs.writeFile(cmdFiles.startupFile, removeStartupCode(content, regionStart, regionEnd));
traceInfo(`Removed activation from CMD activation file at: ${cmdFiles.startupFile}`);
} else {
traceInfo(`CMD activation file at ${startupFile} does not contain activation code`);
traceInfo(`CMD activation file at ${cmdFiles.startupFile} does not contain activation code`);
}
} catch (err) {
traceVerbose(`Failed to remove CMD activation file content`, err);
return false;
}
}

await removeRegistryAutoRun(cmdFiles.mainBatchFile, cmdFiles.regMainBatchFile);

return true;
}

Expand Down Expand Up @@ -311,8 +450,8 @@ export class CmdStartupProvider implements ShellStartupScriptProvider {
}

try {
const { startupFile } = await getCmdFilePaths();
const success = await removeCmdStartup(startupFile, CMD_ENV_KEY);
const cmdFiles = await getCmdFilePaths();
const success = await removeCmdStartup(cmdFiles, CMD_ENV_KEY);
return success ? ShellScriptEditState.Edited : ShellScriptEditState.NotEdited;
} catch (err) {
traceError('Failed to remove CMD startup', err);
Expand Down
146 changes: 146 additions & 0 deletions src/test/features/terminal/shells/cmd/cmdStartup.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as assert from 'assert';
import {
getAutoRunCallCommand,
hasAutoRunCommand,
mergeAutoRunCommand,
removeAutoRunCommand,
splitAutoRunCommands,
} from '../../../../../features/terminal/shells/cmd/cmdStartup';

suite('CMD AutoRun Startup Integration', () => {
const mainBatch = 'C:\\Users\\test\\.cmdrc\\cmd_startup.bat';
const regBatch = '%USERPROFILE%\\.cmdrc\\cmd_startup.bat';
const ourCommand = `if exist "${mainBatch}" call "${mainBatch}"`;
const regOurCommand = `if exist "${regBatch}" call "${regBatch}"`;

suite('getAutoRunCallCommand', () => {
test('formats invocation command with if exist call syntax', () => {
assert.strictEqual(getAutoRunCallCommand(mainBatch), ourCommand);
});
});

suite('splitAutoRunCommands', () => {
test('returns empty array for empty or whitespace strings', () => {
assert.deepStrictEqual(splitAutoRunCommands(''), []);
assert.deepStrictEqual(splitAutoRunCommands(' '), []);
});

test('splits commands delimited by single &', () => {
const input = 'cmd1 & cmd2 & cmd3';
assert.deepStrictEqual(splitAutoRunCommands(input), ['cmd1', 'cmd2', 'cmd3']);
});

test('splits commands delimited by &&', () => {
const input = 'cmd1 && cmd2';
assert.deepStrictEqual(splitAutoRunCommands(input), ['cmd1', 'cmd2']);
});

test('preserves & inside double-quoted paths or arguments', () => {
const input = '"C:\\tools & scripts\\init.cmd" & doskey /macrofile=macros.txt';
assert.deepStrictEqual(splitAutoRunCommands(input), [
'"C:\\tools & scripts\\init.cmd"',
'doskey /macrofile=macros.txt',
]);
});
});

suite('hasAutoRunCommand', () => {
test('returns false for undefined or empty AutoRun', () => {
assert.strictEqual(hasAutoRunCommand(undefined, mainBatch), false);
assert.strictEqual(hasAutoRunCommand('', mainBatch), false);
assert.strictEqual(hasAutoRunCommand(' ', mainBatch), false);
});

test('returns false when AutoRun contains only other commands', () => {
assert.strictEqual(hasAutoRunCommand('"C:\\tools\\init.cmd"', mainBatch), false);
assert.strictEqual(hasAutoRunCommand('cmd1 & cmd2', mainBatch, regBatch), false);
});

test('returns true when AutoRun contains mainBatchFile path', () => {
assert.strictEqual(hasAutoRunCommand(ourCommand, mainBatch), true);
assert.strictEqual(hasAutoRunCommand(`"C:\\tools\\init.cmd" & ${ourCommand}`, mainBatch), true);
});

test('returns true when AutoRun contains regMainBatchFile path', () => {
assert.strictEqual(hasAutoRunCommand(regOurCommand, mainBatch, regBatch), true);
assert.strictEqual(hasAutoRunCommand(`"C:\\tools\\init.cmd" & ${regOurCommand}`, mainBatch, regBatch), true);
});
});

suite('mergeAutoRunCommand', () => {
test('returns our command when AutoRun is undefined or empty', () => {
assert.strictEqual(mergeAutoRunCommand(undefined, mainBatch), ourCommand);
assert.strictEqual(mergeAutoRunCommand('', mainBatch), ourCommand);
assert.strictEqual(mergeAutoRunCommand(' ', mainBatch), ourCommand);
});

test('chains our command after user command with &', () => {
const existing = '"C:\\tools\\init.cmd"';
const expected = `"${'C:\\tools\\init.cmd'}" & ${ourCommand}`;
assert.strictEqual(mergeAutoRunCommand(existing, mainBatch), expected);
});

test('is idempotent when AutoRun already contains our command', () => {
const existing = `"C:\\tools\\init.cmd" & ${ourCommand}`;
assert.strictEqual(mergeAutoRunCommand(existing, mainBatch), existing);
});

test('is idempotent when AutoRun contains regMainBatchFile', () => {
const existing = `"C:\\tools\\init.cmd" & ${regOurCommand}`;
assert.strictEqual(mergeAutoRunCommand(existing, mainBatch, regBatch), existing);
});

test('chains when user content contains multiple commands', () => {
const existing = 'cmd1 & cmd2';
const expected = `cmd1 & cmd2 & ${ourCommand}`;
assert.strictEqual(mergeAutoRunCommand(existing, mainBatch), expected);
});
});

suite('removeAutoRunCommand', () => {
test('returns undefined when AutoRun is undefined, empty, or whitespace', () => {
assert.strictEqual(removeAutoRunCommand(undefined, mainBatch), undefined);
assert.strictEqual(removeAutoRunCommand('', mainBatch), undefined);
assert.strictEqual(removeAutoRunCommand(' ', mainBatch), undefined);
});

test('returns undefined when AutoRun contains only our command', () => {
assert.strictEqual(removeAutoRunCommand(ourCommand, mainBatch), undefined);
assert.strictEqual(removeAutoRunCommand(regOurCommand, mainBatch, regBatch), undefined);
});

test('preserves user content when AutoRun does not contain our command', () => {
const existing = '"C:\\tools\\init.cmd"';
assert.strictEqual(removeAutoRunCommand(existing, mainBatch), existing);

const multi = 'cmd1 & cmd2';
assert.strictEqual(removeAutoRunCommand(multi, mainBatch), multi);
});

test('removes our command when user content is followed by ours', () => {
const existing = `"C:\\tools\\init.cmd" & ${ourCommand}`;
assert.strictEqual(removeAutoRunCommand(existing, mainBatch), '"C:\\tools\\init.cmd"');
});

test('removes our command when our command is followed by user content', () => {
const existing = `${ourCommand} & "C:\\tools\\init.cmd"`;
assert.strictEqual(removeAutoRunCommand(existing, mainBatch), '"C:\\tools\\init.cmd"');
});

test('removes our command when our command is sandwiched in the middle', () => {
const existing = `cmd1 & ${ourCommand} & cmd2`;
assert.strictEqual(removeAutoRunCommand(existing, mainBatch), 'cmd1 & cmd2');
});

test('removes regMainBatchFile variant when sandwiched in the middle', () => {
const existing = `cmd1 & ${regOurCommand} & cmd2`;
assert.strictEqual(removeAutoRunCommand(existing, mainBatch, regBatch), 'cmd1 & cmd2');
});

test('preserves complex user commands with quotes and parameters', () => {
const existing = `"C:\\My Tools\\init.cmd" /arg1 & ${ourCommand} & doskey /macrofile="C:\\My Macros\\m.txt"`;
const expected = '"C:\\My Tools\\init.cmd" /arg1 & doskey /macrofile="C:\\My Macros\\m.txt"';
assert.strictEqual(removeAutoRunCommand(existing, mainBatch), expected);
});
});
});