Skip to content

Add pathMappings option to debugger #2251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(IReadOnl
// path which may or may not exist.
psCommand
.AddScript(_setPSBreakpointLegacy, useLocalScope: true)
.AddParameter("Script", breakpoint.Source)
.AddParameter("Script", breakpoint.MappedSource ?? breakpoint.Source)
.AddParameter("Line", breakpoint.LineNumber);

// Check if the user has specified the column number for the breakpoint.
Expand All @@ -219,7 +219,16 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetBreakpointsAsync(IReadOnl
IEnumerable<Breakpoint> setBreakpoints = await _executionService
.ExecutePSCommandAsync<Breakpoint>(psCommand, CancellationToken.None)
.ConfigureAwait(false);
configuredBreakpoints.AddRange(setBreakpoints.Select((breakpoint) => BreakpointDetails.Create(breakpoint)));

int bpIdx = 0;
foreach (Breakpoint setBp in setBreakpoints)
{
BreakpointDetails setBreakpoint = BreakpointDetails.Create(
setBp,
sourceBreakpoint: breakpoints[bpIdx]);
configuredBreakpoints.Add(setBreakpoint);
bpIdx++;
}
}
return configuredBreakpoints;
}
Expand Down
94 changes: 80 additions & 14 deletions src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host;
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility;
using Microsoft.PowerShell.EditorServices.Services.TextDocument;
using Microsoft.PowerShell.EditorServices.Utility;

namespace Microsoft.PowerShell.EditorServices.Services
Expand Down Expand Up @@ -49,6 +48,7 @@ internal class DebugService
private VariableContainerDetails scriptScopeVariables;
private VariableContainerDetails localScopeVariables;
private StackFrameDetails[] stackFrameDetails;
private PathMapping[] _pathMappings;

private readonly SemaphoreSlim debugInfoHandle = AsyncUtils.CreateSimpleLockingSemaphore();
#endregion
Expand Down Expand Up @@ -123,22 +123,22 @@ public DebugService(
/// <summary>
/// Sets the list of line breakpoints for the current debugging session.
/// </summary>
/// <param name="scriptFile">The ScriptFile in which breakpoints will be set.</param>
/// <param name="scriptPath">The path in which breakpoints will be set.</param>
/// <param name="breakpoints">BreakpointDetails for each breakpoint that will be set.</param>
/// <param name="clearExisting">If true, causes all existing breakpoints to be cleared before setting new ones.</param>
/// <param name="skipRemoteMapping">If true, skips the remote file manager mapping of the script path.</param>
/// <returns>An awaitable Task that will provide details about the breakpoints that were set.</returns>
public async Task<IReadOnlyList<BreakpointDetails>> SetLineBreakpointsAsync(
ScriptFile scriptFile,
string scriptPath,
IReadOnlyList<BreakpointDetails> breakpoints,
bool clearExisting = true)
bool clearExisting = true,
bool skipRemoteMapping = false)
{
DscBreakpointCapability dscBreakpoints = await _debugContext.GetDscBreakpointCapabilityAsync().ConfigureAwait(false);

string scriptPath = scriptFile.FilePath;

_psesHost.Runspace.ThrowCancelledIfUnusable();
// Make sure we're using the remote script path
if (_psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null)
if (!skipRemoteMapping && _psesHost.CurrentRunspace.IsOnRemoteMachine && _remoteFileManager is not null)
{
if (!_remoteFileManager.IsUnderRemoteTempPath(scriptPath))
{
Expand All @@ -162,7 +162,7 @@ public async Task<IReadOnlyList<BreakpointDetails>> SetLineBreakpointsAsync(
{
if (clearExisting)
{
await _breakpointService.RemoveAllBreakpointsAsync(scriptFile.FilePath).ConfigureAwait(false);
await _breakpointService.RemoveAllBreakpointsAsync(scriptPath).ConfigureAwait(false);
}

return await _breakpointService.SetBreakpointsAsync(breakpoints).ConfigureAwait(false);
Expand Down Expand Up @@ -603,6 +603,59 @@ public VariableScope[] GetVariableScopes(int stackFrameId)
};
}

internal void SetPathMappings(PathMapping[] pathMappings) => _pathMappings = pathMappings;

internal void UnsetPathMappings() => _pathMappings = null;

internal bool TryGetMappedLocalPath(string remotePath, out string localPath)
{
if (_pathMappings is not null)
{
foreach (PathMapping mapping in _pathMappings)
{
if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot))
{
// If either path mapping is null, we can't map the path.
continue;
}

if (remotePath.StartsWith(mapping.RemoteRoot, StringComparison.OrdinalIgnoreCase))
{
localPath = mapping.LocalRoot + remotePath.Substring(mapping.RemoteRoot.Length);
Comment on lines +622 to +624
Copy link
Preview

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

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

Path comparison using StartsWith without ensuring proper path separators could lead to incorrect matches. For example, '/home/user' would incorrectly match '/home/username/file.ps1'. Consider using proper path comparison logic that respects directory boundaries.

Suggested change
if (remotePath.StartsWith(mapping.RemoteRoot, StringComparison.OrdinalIgnoreCase))
{
localPath = mapping.LocalRoot + remotePath.Substring(mapping.RemoteRoot.Length);
string normalizedRemotePath = Path.GetFullPath(remotePath);
string normalizedRemoteRoot = Path.GetFullPath(mapping.RemoteRoot);
if (normalizedRemotePath.StartsWith(normalizedRemoteRoot, StringComparison.OrdinalIgnoreCase) &&
Path.GetRelativePath(normalizedRemoteRoot, normalizedRemotePath).IndexOf("..", StringComparison.Ordinal) != 0)
{
localPath = Path.Combine(mapping.LocalRoot, Path.GetRelativePath(normalizedRemoteRoot, normalizedRemotePath));

Copilot uses AI. Check for mistakes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case it is use supplied values. if they wish to not use a directory separator at the end of the localRoot or remoteRoot then that's their decision. The path mappings is designed to replace the root substring with the specified equivalent.

return true;
}
}
}

localPath = null;
return false;
}

internal bool TryGetMappedRemotePath(string localPath, out string remotePath)
{
if (_pathMappings is not null)
{
foreach (PathMapping mapping in _pathMappings)
{
if (string.IsNullOrWhiteSpace(mapping.LocalRoot) || string.IsNullOrWhiteSpace(mapping.RemoteRoot))
{
// If either path mapping is null, we can't map the path.
continue;
}

if (localPath.StartsWith(mapping.LocalRoot, StringComparison.OrdinalIgnoreCase))
Copy link
Preview

Copilot AI Jul 31, 2025

Choose a reason for hiding this comment

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

Same path comparison issue as with TryGetMappedLocalPath. Using StartsWith without proper path boundary checking could lead to incorrect path mappings.

Suggested change
if (localPath.StartsWith(mapping.LocalRoot, StringComparison.OrdinalIgnoreCase))
if (localPath.StartsWith(mapping.LocalRoot, StringComparison.OrdinalIgnoreCase) &&
(localPath.Length == mapping.LocalRoot.Length ||
localPath[mapping.LocalRoot.Length] == System.IO.Path.DirectorySeparatorChar ||
localPath[mapping.LocalRoot.Length] == System.IO.Path.AltDirectorySeparatorChar))

Copilot uses AI. Check for mistakes.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

{
// If the local path starts with the local path mapping, we can replace it with the remote path.
remotePath = mapping.RemoteRoot + localPath.Substring(mapping.LocalRoot.Length);
return true;
}
}
}

remotePath = null;
return false;
}

#endregion

#region Private Methods
Expand Down Expand Up @@ -873,14 +926,19 @@ private async Task FetchStackFramesAsync(string scriptNameOverride)
StackFrameDetails stackFrameDetailsEntry = StackFrameDetails.Create(callStackFrame, autoVariables, commandVariables);
string stackFrameScriptPath = stackFrameDetailsEntry.ScriptPath;

if (scriptNameOverride is not null
&& string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
bool isNoScriptPath = string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath);
if (scriptNameOverride is not null && isNoScriptPath)
{
stackFrameDetailsEntry.ScriptPath = scriptNameOverride;
}
else if (TryGetMappedLocalPath(stackFrameScriptPath, out string localMappedPath)
&& !isNoScriptPath)
{
stackFrameDetailsEntry.ScriptPath = localMappedPath;
}
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
&& _remoteFileManager is not null
&& !string.Equals(stackFrameScriptPath, StackFrameDetails.NoFileScriptPath))
&& !isNoScriptPath)
{
stackFrameDetailsEntry.ScriptPath =
_remoteFileManager.GetMappedPath(stackFrameScriptPath, _psesHost.CurrentRunspace);
Expand Down Expand Up @@ -981,9 +1039,13 @@ await _executionService.ExecutePSCommandAsync<PSObject>(
// Begin call stack and variables fetch. We don't need to block here.
StackFramesAndVariablesFetched = FetchStackFramesAndVariablesAsync(noScriptName ? localScriptPath : null);

if (!noScriptName && TryGetMappedLocalPath(e.InvocationInfo.ScriptName, out string mappedLocalPath))
{
localScriptPath = mappedLocalPath;
}
// If this is a remote connection and the debugger stopped at a line
// in a script file, get the file contents
if (_psesHost.CurrentRunspace.IsOnRemoteMachine
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
&& _remoteFileManager is not null
&& !noScriptName)
{
Expand Down Expand Up @@ -1034,8 +1096,12 @@ private void OnBreakpointUpdated(object sender, BreakpointUpdatedEventArgs e)
{
// TODO: This could be either a path or a script block!
string scriptPath = lineBreakpoint.Script;
if (_psesHost.CurrentRunspace.IsOnRemoteMachine
&& _remoteFileManager is not null)
if (TryGetMappedLocalPath(scriptPath, out string mappedLocalPath))
{
scriptPath = mappedLocalPath;
}
else if (_psesHost.CurrentRunspace.IsOnRemoteMachine
&& _remoteFileManager is not null)
{
string mappedPath = _remoteFileManager.GetMappedPath(scriptPath, _psesHost.CurrentRunspace);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public static Breakpoint SetBreakpoint(Debugger debugger, BreakpointDetailsBase
{
BreakpointDetails lineBreakpoint => SetLineBreakpointDelegate(
debugger,
lineBreakpoint.Source,
lineBreakpoint.MappedSource ?? lineBreakpoint.Source,
lineBreakpoint.LineNumber,
lineBreakpoint.ColumnNumber ?? 0,
actionScriptBlock,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ internal sealed class BreakpointDetails : BreakpointDetailsBase
/// </summary>
public string Source { get; private set; }

/// <summary>
/// Gets the source where the breakpoint is mapped to, will be null if no mapping exists. Used only for debug purposes.
/// </summary>
public string MappedSource { get; private set; }

/// <summary>
/// Gets the line number at which the breakpoint is set.
/// </summary>
Expand All @@ -50,14 +55,16 @@ private BreakpointDetails()
/// <param name="condition"></param>
/// <param name="hitCondition"></param>
/// <param name="logMessage"></param>
/// <param name="mappedSource"></param>
/// <returns></returns>
internal static BreakpointDetails Create(
string source,
int line,
int? column = null,
string condition = null,
string hitCondition = null,
string logMessage = null)
string logMessage = null,
string mappedSource = null)
{
Validate.IsNotNullOrEmptyString(nameof(source), source);

Expand All @@ -69,7 +76,8 @@ internal static BreakpointDetails Create(
ColumnNumber = column,
Condition = condition,
HitCondition = hitCondition,
LogMessage = logMessage
LogMessage = logMessage,
MappedSource = mappedSource
};
}

Expand All @@ -79,10 +87,12 @@ internal static BreakpointDetails Create(
/// </summary>
/// <param name="breakpoint">The Breakpoint instance from which details will be taken.</param>
/// <param name="updateType">The BreakpointUpdateType to determine if the breakpoint is verified.</param>
/// /// <param name="sourceBreakpoint">The breakpoint source from the debug client, if any.</param>
/// <returns>A new instance of the BreakpointDetails class.</returns>
internal static BreakpointDetails Create(
Breakpoint breakpoint,
BreakpointUpdateType updateType = BreakpointUpdateType.Set)
BreakpointUpdateType updateType = BreakpointUpdateType.Set,
BreakpointDetails sourceBreakpoint = null)
{
Validate.IsNotNull(nameof(breakpoint), breakpoint);

Expand All @@ -96,10 +106,11 @@ internal static BreakpointDetails Create(
{
Id = breakpoint.Id,
Verified = updateType != BreakpointUpdateType.Disabled,
Source = lineBreakpoint.Script,
Source = sourceBreakpoint?.MappedSource is not null ? sourceBreakpoint.Source : lineBreakpoint.Script,
LineNumber = lineBreakpoint.Line,
ColumnNumber = lineBreakpoint.Column,
Condition = lineBreakpoint.Action?.ToString()
Condition = lineBreakpoint.Action?.ToString(),
MappedSource = sourceBreakpoint?.MappedSource,
};

if (lineBreakpoint.Column > 0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,14 +79,20 @@ public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request
}

// At this point, the source file has been verified as a PowerShell script.
string mappedSource = null;
if (_debugService.TryGetMappedRemotePath(scriptFile.FilePath, out string remoteMappedPath))
{
mappedSource = remoteMappedPath;
}
IReadOnlyList<BreakpointDetails> breakpointDetails = request.Breakpoints
.Select((srcBreakpoint) => BreakpointDetails.Create(
scriptFile.FilePath,
srcBreakpoint.Line,
srcBreakpoint.Column,
srcBreakpoint.Condition,
srcBreakpoint.HitCondition,
srcBreakpoint.LogMessage)).ToList();
srcBreakpoint.LogMessage,
mappedSource: mappedSource)).ToList();

// If this is a "run without debugging (Ctrl+F5)" session ignore requests to set breakpoints.
IReadOnlyList<BreakpointDetails> updatedBreakpointDetails = breakpointDetails;
Expand All @@ -98,8 +104,9 @@ public async Task<SetBreakpointsResponse> Handle(SetBreakpointsArguments request
{
updatedBreakpointDetails =
await _debugService.SetLineBreakpointsAsync(
scriptFile,
breakpointDetails).ConfigureAwait(false);
mappedSource ?? scriptFile.FilePath,
breakpointDetails,
skipRemoteMapping: mappedSource is not null).ConfigureAwait(false);
}
catch (Exception e)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public async Task<DisconnectResponse> Handle(DisconnectArguments request, Cancel
// We should instead ensure that the debugger is in some valid state, lock it and then tear things down

_debugEventHandlerService.UnregisterEventHandlers();
_debugService.UnsetPathMappings();

if (!_debugStateService.ExecutionCompleted)
{
Expand Down
Loading
Loading