Skip to content
Closed
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
7 changes: 5 additions & 2 deletions extension/src/editor/AspireEditorCommandProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { noAppHostInWorkspace } from '../loc/strings';
import { getResourceDebuggerExtensions } from '../debugger/debuggerExtensions';
import { AspireCommandType } from '../dcp/types';
import { aspireConfigFileName, getAppHostPathFromConfig, readJsonFile } from '../utils/cliTypes';
import { resolveCanonicalPath } from '../utils/io';

export class AspireEditorCommandProvider implements vscode.Disposable {
private _workspaceAppHostPath: string | null = null;
Expand Down Expand Up @@ -187,7 +188,9 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
const appHostPath = path.isAbsolute(appHostRelativePath)
? appHostRelativePath
: path.join(configDir, appHostRelativePath);
onChangeAppHostPath(appHostPath);
// Resolve to canonical on-disk casing to prevent case mismatches
// when the path is passed to the CLI via --apphost (see #15588)
onChangeAppHostPath(resolveCanonicalPath(appHostPath));
}
catch {
onChangeAppHostPath(null);
Expand All @@ -200,7 +203,7 @@ export class AspireEditorCommandProvider implements vscode.Disposable {
*/
public async getAppHostPath(): Promise<string | null> {
if (vscode.window.activeTextEditor && await this.isAppHostFile(vscode.window.activeTextEditor.document.uri.fsPath)) {
return vscode.window.activeTextEditor.document.uri.fsPath;
return resolveCanonicalPath(vscode.window.activeTextEditor.document.uri.fsPath);
}

return this._workspaceAppHostPath;
Expand Down
132 changes: 132 additions & 0 deletions extension/src/test/io.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import * as assert from 'assert';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveCanonicalPath } from '../utils/io';

suite('utils/io tests', () => {

test('resolveCanonicalPath returns canonical casing for existing file', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-test-'));

// Resolve tmpDir itself first so symlinks (e.g., /var -> /private/var on macOS)
// don't interfere with comparisons
const canonicalTmpDir = fs.realpathSync.native(tmpDir);
const filePath = path.join(canonicalTmpDir, 'MyAppHost.csproj');
fs.writeFileSync(filePath, '');

try {
const resolved = resolveCanonicalPath(filePath);

assert.ok(fs.existsSync(resolved), 'Resolved path should exist');
assert.ok(path.isAbsolute(resolved), 'Resolved path should be absolute');
assert.strictEqual(resolved, filePath, 'Canonical path should match when input casing is already correct');
} finally {
fs.unlinkSync(filePath);
fs.rmdirSync(canonicalTmpDir);
}
});

test('resolveCanonicalPath resolves wrong-cased path on case-insensitive filesystem', function () {
// This test is meaningful only on case-insensitive file systems (macOS, Windows)
if (process.platform === 'linux') {
this.skip();
return;
}

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-test-'));
const canonicalTmpDir = fs.realpathSync.native(tmpDir);
const filePath = path.join(canonicalTmpDir, 'MyAppHost.csproj');
fs.writeFileSync(filePath, '');

try {
// Pass a wrong-cased version of the path
const wrongCased = path.join(canonicalTmpDir, 'myapphost.csproj');
const resolved = resolveCanonicalPath(wrongCased);

// On case-insensitive FS, realpathSync.native returns the actual on-disk casing
assert.strictEqual(path.basename(resolved), 'MyAppHost.csproj',
`Expected resolved path to have on-disk casing "MyAppHost.csproj", got "${path.basename(resolved)}"`);
assert.strictEqual(resolved, filePath, 'Full resolved path should match the on-disk canonical path');
} finally {
fs.unlinkSync(filePath);
fs.rmdirSync(canonicalTmpDir);
}
});

test('resolveCanonicalPath returns original path for non-existent file', () => {
const fakePath = path.join(os.tmpdir(), 'non-existent-dir-15588', 'DoesNotExist.csproj');
const resolved = resolveCanonicalPath(fakePath);

assert.strictEqual(resolved, fakePath, 'Should return original path when file does not exist');
});

test('resolveCanonicalPath handles symlinks', function () {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-test-'));
const canonicalTmpDir = fs.realpathSync.native(tmpDir);
const realFile = path.join(canonicalTmpDir, 'RealFile.csproj');
const symlink = path.join(canonicalTmpDir, 'SymLink.csproj');

fs.writeFileSync(realFile, '');

try {
fs.symlinkSync(realFile, symlink);
} catch {
// Symlinks may not be available (e.g., some Windows configs)
this.skip();
return;
}

try {
const resolved = resolveCanonicalPath(symlink);
assert.ok(resolved.endsWith('RealFile.csproj'),
`Expected symlink to resolve to "RealFile.csproj", got "${path.basename(resolved)}"`);
} finally {
fs.unlinkSync(symlink);
fs.unlinkSync(realFile);
fs.rmdirSync(canonicalTmpDir);
}
});

test('resolveCanonicalPath handles directory paths', () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-test-'));
const canonicalTmpDir = fs.realpathSync.native(tmpDir);

try {
const resolved = resolveCanonicalPath(canonicalTmpDir);
assert.ok(fs.existsSync(resolved), 'Resolved directory path should exist');
assert.ok(path.isAbsolute(resolved), 'Resolved directory path should be absolute');
} finally {
fs.rmdirSync(canonicalTmpDir);
}
});

test('resolveCanonicalPath normalizes parent directory casing on case-insensitive filesystem', function () {
// Verifies that case normalization works for parent directories too,
// not just the filename component
if (process.platform === 'linux') {
this.skip();
return;
}

const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'AspireTestDir-'));
const canonicalTmpDir = fs.realpathSync.native(tmpDir);
const subDir = path.join(canonicalTmpDir, 'SubFolder');
fs.mkdirSync(subDir);
const filePath = path.join(subDir, 'AppHost.csproj');
fs.writeFileSync(filePath, '');

try {
// Construct a path with wrong casing in both directory and file name
const wrongCased = path.join(canonicalTmpDir, 'subfolder', 'apphost.csproj');
const resolved = resolveCanonicalPath(wrongCased);

assert.ok(resolved.includes('SubFolder'), `Expected "SubFolder" in resolved path, got "${resolved}"`);
assert.ok(resolved.endsWith('AppHost.csproj'), `Expected "AppHost.csproj" at end, got "${path.basename(resolved)}"`);
} finally {
fs.unlinkSync(filePath);
fs.rmdirSync(subDir);
fs.rmdirSync(canonicalTmpDir);
}
});
});
17 changes: 17 additions & 0 deletions extension/src/utils/io.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { realpathSync } from 'fs';
import { access, stat } from 'fs/promises';

/**
* Resolves a file path to its canonical on-disk casing using the native OS realpath.
* On case-insensitive file systems (macOS, Windows), this returns the path with
* the actual casing stored on disk, preventing case mismatches when paths are
* compared or hashed by other tools (e.g., the Aspire CLI backchannel socket lookup).
* Falls back to the original path if the native realpath call fails for any reason
* (e.g., the file does not exist).
*/
export function resolveCanonicalPath(p: string): string {
try {
return realpathSync.native(p);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the AppHost was started from a symlinked path (for example a symlinked repo root, or a config entry that points through a symlink)? realpathSync.native() does more than fix casing here: it dereferences the symlink and changes the literal path we send back via --apphost.

That breaks the current CLI contract because the server side still keys AppHost state off Path.GetFullPath(appPath) rather than a realpath/canonical target (DotNetBasedAppHostServerProject and PrebuiltAppHostServer both hash the full path string, and AuxiliaryBackchannelMonitor scope checks also use Path.GetFullPath). Path.GetFullPath preserves the symlink text, so a running AppHost started as /repo-link/AppHost.csproj is distinct from /actual/repo/AppHost.csproj. After this change, restart/stop/logs can fail to find an already-running AppHost again, just under a different path variant.

This is a regression from the old behavior, which forwarded the configured/opened path unchanged. The fix direction seems to be: normalize casing without resolving symlinks, or make the CLI canonicalize AppHost identity the same way on both sides.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fair. The change will need to be made in the CLI. Moving area path and closing this PR

} catch {
return p;
Comment thread
adamint marked this conversation as resolved.
}
}

export async function doesFileExist(filePath: string): Promise<boolean> {
try {
await access(filePath);
Expand Down
10 changes: 7 additions & 3 deletions extension/src/views/AppHostDataRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AspireTerminalProvider } from '../utils/AspireTerminalProvider';
import { extensionLogOutputChannel } from '../utils/logging';
import { EnvironmentVariables } from '../utils/environment';
import { errorFetchingAppHosts } from '../loc/strings';
import { resolveCanonicalPath } from '../utils/io';

export interface ResourceUrlJson {
name: string | null;
Expand Down Expand Up @@ -231,9 +232,12 @@ export class AppHostDataRepository {
const appHostPath = parsed.selected_project_file
?? (parsed.all_project_file_candidates.length === 1 ? parsed.all_project_file_candidates[0] : null);
if (appHostPath) {
this._workspaceAppHostPath = appHostPath;
this._workspaceAppHostName = shortenPath(appHostPath);
extensionLogOutputChannel.info(`Workspace apphost resolved: ${appHostPath}`);
// Resolve to canonical on-disk casing to prevent case mismatches
// when the path is passed to the CLI via --apphost (see #15588)
const canonicalAppHostPath = resolveCanonicalPath(appHostPath);
this._workspaceAppHostPath = canonicalAppHostPath;
this._workspaceAppHostName = shortenPath(canonicalAppHostPath);
extensionLogOutputChannel.info(`Workspace apphost resolved: ${canonicalAppHostPath}`);
this._onDidChangeData.fire();
}
}
Expand Down
Loading