Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@
"clean": "node -e \"require('fs').rmSync('bin', {recursive: true, force: true}); require('fs').rmSync('out', {recursive: true, force: true}); require('fs').rmSync('dist', {recursive: true, force: true});\"",
"download-cli": "pwsh -NoProfile -Command \"& ./scripts/download-cli.ps1\"",
"download-cli:latest": "pwsh -NoProfile -Command \"& ./scripts/download-cli.ps1 -Tag latest\"",
"test:unit": "tsc -p ./ && node -e \"require('fs').cpSync('src/test/fixtures','out/test/fixtures',{recursive:true})\" && mocha out/test/project-detection.test.js && npx tsx --test src/test/artifact-types.test.ts src/test/arch-detection.test.ts src/test/debugger-resolver.test.ts src/test/extension-field-validator.test.ts src/test/extension-templates.test.ts src/test/manifest-edge-cases.test.ts src/test/manifest-parser.test.ts src/test/manifest-validator.test.ts src/test/project-resolver.test.ts src/test/redos-prevention.test.ts src/test/xml-utils.test.ts src/test/shell-escape.test.ts src/test/noop-debug-adapter.test.ts src/test/pack-result.test.ts src/test/sign-utils.test.ts src/test/sign-flow.test.ts"
"test:unit": "tsc -p ./ && node -e \"require('fs').cpSync('src/test/fixtures','out/test/fixtures',{recursive:true})\" && mocha out/test/project-detection.test.js && npx tsx --test src/test/artifact-types.test.ts src/test/arch-detection.test.ts src/test/debugger-resolver.test.ts src/test/extension-field-validator.test.ts src/test/extension-templates.test.ts src/test/manifest-edge-cases.test.ts src/test/manifest-parser.test.ts src/test/manifest-validator.test.ts src/test/project-resolver.test.ts src/test/redos-prevention.test.ts src/test/xml-utils.test.ts src/test/shell-escape.test.ts src/test/noop-debug-adapter.test.ts src/test/pack-result.test.ts src/test/sign-utils.test.ts src/test/sign-flow.test.ts src/test/working-directory.test.ts"
},
"dependencies": {
"@xmldom/xmldom": "^0.9.9",
Expand Down
20 changes: 12 additions & 8 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
escapePowerShellArg,
resolveWindowsPowerShellPath,
isUsableElevatedCliPath,
decideElevatedWinappCommand
decideElevatedWinappCommand,
resolveWorkingDirectory
} from './winapp-cli-utils';
import { detectProjects, deduplicateBuildOutputFolders, BUILD_OUTPUT_EXCLUDE_GLOB, BUILD_OUTPUT_MAX_RESULTS } from './project-detection';
import { resolveProjectDirectory as resolveProjectDirectoryCore } from './project-resolver';
Expand Down Expand Up @@ -821,9 +822,15 @@ class WinAppDebugConfigurationProvider implements vscode.DebugConfigurationProvi
// runs — this avoids showing the debugger toolbar on failure.
const inputFolder: string | undefined = config.inputFolder;
if (inputFolder) {
let cwd = folder.uri.fsPath;
if (config.workingDirectory) {
cwd = config.workingDirectory;
let cwd: string;
try {
cwd = resolveWorkingDirectory(folder.uri.fsPath, config.workingDirectory);
} catch (error) {
// An unusable workingDirectory is a launch.json authoring problem, so
// surface it here and cancel the session rather than letting the
// adapter factory fail later with the debugger toolbar already shown.
vscode.window.showErrorMessage(error instanceof Error ? error.message : String(error));
return undefined;
}
const result = await validateInputFolder(inputFolder, cwd);
if (!result.valid) {
Expand Down Expand Up @@ -859,10 +866,7 @@ class WinAppDebugAdapterFactory implements vscode.DebugAdapterDescriptorFactory
// If not set in launch.json, search for folders containing .exe
// files and let the user pick one.
let inputFolder: string | undefined = config.inputFolder;
let cwd = folder.uri.fsPath;
if (config.workingDirectory) {
cwd = config.workingDirectory;
}
const cwd = resolveWorkingDirectory(folder.uri.fsPath, config.workingDirectory);

if (!inputFolder) {
const dirs = await findBuildOutputFolders(folder.uri.fsPath);
Expand Down
107 changes: 107 additions & 0 deletions src/test/working-directory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Unit tests for resolveWorkingDirectory (src/winapp-cli-utils.ts).
*
* The debug adapter passes the launch.json `workingDirectory` straight to
* `spawn`, so a relative value would resolve against the extension host's
* process cwd instead of the workspace folder. These tests pin the resolution
* behavior for unset, relative, root-relative, fully qualified, and
* drive-relative values.
*
* Windows-specific expectations are written out literally rather than computed
* with `path.resolve`, so a regression in the helper cannot be masked by the
* test reusing the same operation it is meant to verify.
*
* Run: npx tsx --test src/test/working-directory.test.ts
*/

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import * as path from 'node:path';
import { resolveWorkingDirectory } from '../winapp-cli-utils';

const isWindows = path.sep === '\\';

describe('resolveWorkingDirectory', () => {
const workspace = path.resolve(isWindows ? 'C:\\repos\\MyApp' : '/repos/MyApp');

it('falls back to the workspace folder when workingDirectory is unset', () => {
assert.equal(resolveWorkingDirectory(workspace, undefined), workspace);
});

it('falls back to the workspace folder for an empty workingDirectory', () => {
assert.equal(resolveWorkingDirectory(workspace, ''), workspace);
});

it('resolves a relative workingDirectory against the workspace folder', () => {
assert.equal(
resolveWorkingDirectory(workspace, 'subdir'),
path.join(workspace, 'subdir')
);
});

it('resolves an explicitly dot-prefixed relative path against the workspace folder', () => {
assert.equal(
resolveWorkingDirectory(workspace, './subdir'),
path.join(workspace, 'subdir')
);
});

it('resolves a parent-relative path against the workspace folder', () => {
assert.equal(
resolveWorkingDirectory(workspace, '../sibling'),
path.resolve(workspace, '..', 'sibling')
);
});

it('does not resolve against the process cwd', () => {
const resolved = resolveWorkingDirectory(workspace, 'out');
assert.equal(resolved, path.join(workspace, 'out'));
assert.notEqual(resolved, path.resolve(process.cwd(), 'out'));
});

it('leaves a fully qualified workingDirectory untouched', () => {
const absolute = path.resolve(isWindows ? 'C:\\other\\place' : '/other/place');
assert.equal(resolveWorkingDirectory(workspace, absolute), absolute);
});

if (isWindows) {
it('anchors a root-relative path to the workspace drive, not the host drive', () => {
// "\out" is absolute per path.isAbsolute but names no drive, so spawn
// would resolve it against whichever drive the extension host is on.
assert.equal(resolveWorkingDirectory('C:\\repos\\MyApp', '\\out'), 'C:\\out');
});

it('anchors a root-relative path to a non-C workspace drive', () => {
assert.equal(resolveWorkingDirectory('D:\\work\\MyApp', '\\out'), 'D:\\out');
});

it('keeps a fully qualified path on its own drive even when it differs from the workspace', () => {
assert.equal(resolveWorkingDirectory('D:\\work\\MyApp', 'C:\\tools'), 'C:\\tools');
});

it('resolves a plain relative path onto the workspace drive', () => {
assert.equal(resolveWorkingDirectory('D:\\work\\MyApp', 'out'), 'D:\\work\\MyApp\\out');
});

it('rejects a drive-relative path that matches the workspace drive', () => {
assert.throws(
() => resolveWorkingDirectory('C:\\repos\\MyApp', 'C:out'),
/drive-relative/
);
});

it('rejects a drive-relative path on a different drive than the workspace', () => {
assert.throws(
() => resolveWorkingDirectory('D:\\work\\MyApp', 'C:out'),
/drive-relative/
);
});

it('suggests both a workspace-relative and a fully qualified alternative', () => {
assert.throws(
() => resolveWorkingDirectory('C:\\repos\\MyApp', 'C:out'),
(error: Error) => error.message.includes('"out"') && error.message.includes('C:\\out')
);
});
}
});
68 changes: 68 additions & 0 deletions src/winapp-cli-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,71 @@ export function buildElevatedTerminalCommand(cliPath: string, cliArgs: string, w
const innerCommand = `Set-Location -LiteralPath ${escapePowerShellArg(workingDirectory)}; & ${escapePowerShellArg(cliPath)} ${cliArgs}`.trim();
return `Start-Process -FilePath ${escapePowerShellArg(launcherPath)} -Verb RunAs -ArgumentList '-NoExit', '-Command', ${escapePowerShellArg(innerCommand)}`;
}

/**
* Matches a fully qualified Windows path: a drive letter, a colon, and a
* separator (`C:\out`, `c:/out`). Drive-relative values like `C:out` have no
* separator after the colon and deliberately do not match.
*/
const WINDOWS_FULLY_QUALIFIED = /^[a-zA-Z]:[\\/]/;

/** Matches a Windows drive-relative path such as `C:out` or `D:..\sibling`. */
const WINDOWS_DRIVE_RELATIVE = /^[a-zA-Z]:(?![\\/])/;

/**
* Resolves the debug session's working directory against the workspace folder.
*
* `workingDirectory` comes straight from launch.json, so it may be relative
* (`"./out"`), root-relative (`"\out"`), fully qualified (`"C:\out"`), or
* drive-relative (`"C:out"`). Passing a relative value to `spawn` resolves it
* against the extension host's `process.cwd()` rather than the workspace, so
* the app launches from an unrelated directory.
*
* Only fully qualified paths are returned untouched. Root-relative paths keep
* their leading separator but are anchored to the workspace drive, since
* `path.isAbsolute('\\out')` is true on Windows even though the value names no
* drive and would otherwise follow whichever drive the extension host happens
* to be on.
*
* Drive-relative paths are rejected: `C:out` means "the current directory of
* drive C:", which is per-process state this extension cannot observe or
* control, so any resolution would be a guess that silently differs from what
* the user typed.
*
* @param workspacePath Absolute path to the workspace folder.
* @param workingDirectory The launch.json `workingDirectory` value, if set.
* @returns An absolute directory to use as the spawn cwd.
* @throws If `workingDirectory` is drive-relative.
*/
export function resolveWorkingDirectory(workspacePath: string, workingDirectory?: string): string {
if (!workingDirectory) {
return workspacePath;
}

// POSIX: isAbsolute is enough. Windows: require a drive so that "\out"
// falls through to be re-anchored on the workspace drive below.
if (path.sep === '/') {
if (path.isAbsolute(workingDirectory)) {
return workingDirectory;
}
return path.resolve(workspacePath, workingDirectory);
}

if (WINDOWS_FULLY_QUALIFIED.test(workingDirectory)) {
return workingDirectory;
}

if (WINDOWS_DRIVE_RELATIVE.test(workingDirectory)) {
throw new Error(
`launch.json "workingDirectory" value "${workingDirectory}" is drive-relative. ` +
'Drive-relative paths depend on the per-process current directory of that drive, ' +
'so they cannot be resolved reliably. Use a path relative to the workspace ' +
`(for example "${workingDirectory.slice(2) || '.'}") or a fully qualified path ` +
`(for example "${workingDirectory.slice(0, 2)}\\${workingDirectory.slice(2)}").`
);
}

// Root-relative ("\out") and plain relative ("out", "../sibling") values both
// resolve against the workspace, which pins the drive to the workspace drive.
return path.resolve(workspacePath, workingDirectory);
}
Loading