Compare commits

...

2 Commits

Author SHA1 Message Date
Leonard Hecker
05282a311a Begone 2026-04-08 23:20:46 +02:00
Leonard Hecker
99c748c5ef Implement UNC path mangling for OSC 7 via WSL 2026-04-08 23:03:57 +02:00
7 changed files with 241 additions and 91 deletions

View File

@@ -1600,7 +1600,7 @@ namespace winrt::TerminalApp::implementation
// Replace the Starting directory with the CWD, if given
const auto workingDirectory = control.WorkingDirectory();
if (Utils::IsValidDirectory(workingDirectory.c_str()))
if (!workingDirectory.empty())
{
controlSettings.DefaultSettings()->StartingDirectory(workingDirectory);
}
@@ -3607,7 +3607,7 @@ namespace winrt::TerminalApp::implementation
profile = GetClosestProfileForDuplicationOfProfile(profile);
controlSettings = Settings::TerminalSettings::CreateWithProfile(_settings, profile);
const auto workingDirectory = tabImpl->GetActiveTerminalControl().WorkingDirectory();
if (Utils::IsValidDirectory(workingDirectory.c_str()))
if (!workingDirectory.empty())
{
controlSettings.DefaultSettings()->StartingDirectory(workingDirectory);
}

View File

@@ -102,7 +102,7 @@ namespace winrt::TerminalApp::implementation
args.Profile(::Microsoft::Console::Utils::GuidToString(_profile.Guid()));
// If we know the user's working directory use it instead of the profile.
if (const auto dir = _control.WorkingDirectory(); ::Microsoft::Console::Utils::IsValidDirectory(dir.c_str()))
if (const auto dir = _control.WorkingDirectory(); !dir.empty())
{
args.StartingDirectory(dir);
}

View File

@@ -1693,8 +1693,10 @@ namespace winrt::Microsoft::Terminal::Control::implementation
void ControlCore::OpenCWD()
{
const auto workingDirectory = WorkingDirectory();
ShellExecute(nullptr, nullptr, L"explorer", workingDirectory.c_str(), nullptr, SW_SHOW);
if (const auto cwd = WorkingDirectory(); Utils::IsValidDirectory(cwd.c_str()))
{
ShellExecute(nullptr, nullptr, L"explorer", cwd.c_str(), nullptr, SW_SHOW);
}
}
void ControlCore::ClearQuickFix()

View File

@@ -5,6 +5,7 @@
#include "AllShortcutActions.h"
#include "ActionMap.h"
#include "Command.h"
#include <til/io.h>
#include "ActionMap.g.cpp"
@@ -1289,7 +1290,12 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
winrt::hstring currentWorkingDirectory)
{
// enumerate all the parent directories we want to import snippets from
std::filesystem::path directory{ std::wstring_view{ currentWorkingDirectory } };
std::filesystem::path directory;
if (::Microsoft::Console::Utils::IsValidDirectory(currentWorkingDirectory.c_str()))
{
directory.assign(std::wstring_view{ currentWorkingDirectory });
}
std::vector<std::filesystem::path> directories;
while (!directory.empty())
{

View File

@@ -254,6 +254,78 @@ namespace til // Terminal Implementation Library. Also: "Today I Learned"
return ends_with_insensitive_ascii<>(str, prefix);
}
// A textbook Boyer-Moore-Horspool string searcher for case-insensitive ASCII.
// NOTE: Passing non-ASCII to `needle` will cause incorrect results.
template<typename T, typename Traits>
_TIL_INLINEPREFIX size_t find_insensitive_ascii(std::basic_string_view<T, Traits> haystack, std::basic_string_view<T, Traits> needle) noexcept
{
static constexpr auto clamp = [](size_t v) noexcept -> uint8_t {
return static_cast<uint8_t>(std::min<size_t>(v, 255));
};
const auto m = needle.size();
if (m == 0)
{
return 0;
}
if (m > haystack.size())
{
return decltype(haystack)::npos;
}
// Bad-character skip table, indexed by folded character value.
// Only covers [0, 128); characters outside this range use the default skip (m).
uint8_t skip[128];
memset(&skip[0], clamp(m), 128);
for (size_t i = 0; i < m - 1; ++i)
{
const auto ch = tolower_ascii(needle[i]);
if (ch < 128)
{
skip[ch] = clamp(m - 1 - i);
}
}
const auto lastIdx = m - 1;
const auto lastCh = tolower_ascii(needle[lastIdx]);
T ch = 0;
for (size_t pos = 0; pos + m <= haystack.size();)
{
ch = tolower_ascii(haystack[pos + lastIdx]);
if (ch != lastCh)
{
goto retry;
}
// Last char matches — verify the rest left-to-right.
for (size_t j = 0; j < lastIdx; ++j)
{
if (tolower_ascii(haystack[pos + j]) != tolower_ascii(needle[j]))
{
goto retry;
}
}
return pos;
retry:
pos += ch < 128 ? skip[ch] : m;
}
return decltype(haystack)::npos;
}
inline size_t find_insensitive_ascii(std::string_view haystack, std::string_view needle) noexcept
{
return find_insensitive_ascii<>(haystack, needle);
}
inline size_t find_insensitive_ascii(std::wstring_view haystack, std::wstring_view needle) noexcept
{
return find_insensitive_ascii<>(haystack, needle);
}
template<typename T, typename Traits>
constexpr std::basic_string_view<T, Traits> trim(const std::basic_string_view<T, Traits>& str, const T ch) noexcept
{

View File

@@ -409,25 +409,25 @@ void UtilsTests::TestMangleWSLPaths()
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"("wsl" -d X)", startingDirectory);
VERIFY_ARE_EQUAL(LR"("wsl" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(LR"("wsl" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(L"", path);
}
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"("wsl.exe" -d X)", startingDirectory);
VERIFY_ARE_EQUAL(LR"("wsl.exe" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(LR"("wsl.exe" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(L"", path);
}
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"("C:\Windows\system32\wsl.exe" -d X)", startingDirectory);
VERIFY_ARE_EQUAL(LR"("C:\Windows\system32\wsl.exe" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(LR"("C:\Windows\system32\wsl.exe" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(L"", path);
}
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"("C:\windows\system32\wsl" -d X)", startingDirectory);
VERIFY_ARE_EQUAL(LR"("C:\windows\system32\wsl" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(LR"("C:\windows\system32\wsl" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(L"", path);
}
@@ -437,19 +437,20 @@ void UtilsTests::TestMangleWSLPaths()
VERIFY_ARE_EQUAL(L"", path);
}
// MUST NOT MANGLE
// Any wsl.exe is treated as a WSL profile, regardless of directory.
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"("C:\wsl.exe" -d X)", startingDirectory);
VERIFY_ARE_EQUAL(LR"("C:\wsl.exe" -d X)", commandline);
VERIFY_ARE_EQUAL(startingDirectory, path);
VERIFY_ARE_EQUAL(LR"("C:\wsl.exe" --cd "SENTINEL" -d X)", commandline);
VERIFY_ARE_EQUAL(L"", path);
}
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"(C:\wsl.exe)", startingDirectory);
VERIFY_ARE_EQUAL(LR"(C:\wsl.exe)", commandline);
VERIFY_ARE_EQUAL(startingDirectory, path);
VERIFY_ARE_EQUAL(LR"("C:\wsl.exe" --cd "SENTINEL" )", commandline);
VERIFY_ARE_EQUAL(L"", path);
}
// MUST NOT MANGLE
{
auto [commandline, path] = MangleStartingDirectoryForWSL(LR"(wsl --cd C:\)", startingDirectory);
VERIFY_ARE_EQUAL(LR"(wsl --cd C:\)", commandline);

View File

@@ -1016,6 +1016,110 @@ bool Utils::IsRunningElevated()
return isElevated;
}
// Checks whether the command line starts with wsl(.exe) as its executable.
// This is intentionally permissive: ANY wsl.exe is treated as a WSL profile.
// The only goal is to avoid false positives like "cmd /c wsl ..." where wsl
// appears as an argument rather than the executable.
static bool _isWslExe(
std::wstring_view commandLine,
std::wstring_view& outExePath,
std::wstring_view& outArguments)
{
if (commandLine.size() < 3)
{
return false;
}
// Shared by both branches: split arguments after the exe, skipping one optional space.
const auto splitArgs = [&](size_t pos) {
if (pos < commandLine.size() && commandLine[pos] == L' ')
{
pos++;
}
outArguments = til::safe_slice_abs(commandLine, pos, std::wstring_view::npos);
};
// Quoted: the executable path extends from the first to the second double-quote.
if (commandLine.front() == L'"')
{
const auto close = commandLine.find(L'"', 1);
if (close == std::wstring_view::npos)
{
return false;
}
const auto path = commandLine.substr(1, close - 1);
const auto sep = path.find_last_of(L"\\/");
const auto name = sep == std::wstring_view::npos ? path : path.substr(sep + 1);
if (!til::equals_insensitive_ascii(name, L"wsl") &&
!til::equals_insensitive_ascii(name, L"wsl.exe"))
{
return false;
}
outExePath = path;
splitArgs(close + 1);
return true;
}
// Unquoted: find "wsl" as a filename component preceded by '\', '/' or BOL,
// optionally followed by ".exe", then ' ' or end-of-string.
for (size_t searchFrom = 0;;)
{
const auto pos = til::find_insensitive_ascii(commandLine.substr(searchFrom), L"wsl");
if (pos == std::wstring_view::npos)
{
return false;
}
const auto absPos = searchFrom + pos;
searchFrom = absPos + 1;
if (absPos > 0 && commandLine[absPos - 1] != L'\\' && commandLine[absPos - 1] != L'/')
{
continue;
}
auto end = absPos + 3;
auto hasExeSuffix = false;
if (til::equals_insensitive_ascii(til::safe_slice_len(commandLine, end, 4), L".exe"))
{
hasExeSuffix = true;
end += 4;
}
if (end < commandLine.size() && commandLine[end] != L' ')
{
continue;
}
const auto candidate = commandLine.substr(0, end);
// For paths with spaces (e.g. "C:\Program Files\WSL\wsl.exe ..."), the
// boundary between exe and arguments is ambiguous. Verify the file exists.
if (candidate.find(L' ') != std::wstring_view::npos)
{
std::wstring path;
path.reserve(candidate.size() + 4);
path.append(candidate);
if (!hasExeSuffix)
{
path.append(L".exe");
}
const auto attrs = GetFileAttributesW(path.c_str());
if (attrs == INVALID_FILE_ATTRIBUTES || (attrs & FILE_ATTRIBUTE_DIRECTORY) != 0)
{
continue;
}
}
outExePath = candidate;
splitArgs(end);
return true;
}
}
// Function Description:
// - Promotes a starting directory provided to a WSL invocation to a commandline argument.
// This is necessary because WSL has some modicum of support for linux-side directories (!) which
@@ -1023,86 +1127,51 @@ bool Utils::IsRunningElevated()
std::tuple<std::wstring, std::wstring> Utils::MangleStartingDirectoryForWSL(std::wstring_view commandLine,
std::wstring_view startingDirectory)
{
do
// Returns true if arguments contain a bare ~ (end-of-string or followed by space).
// A bare ~ conflicts with --cd; ~/path is fine (e.g. wsl -d Debian ~/blah.sh).
const auto hasBareTilde = [](std::wstring_view args) {
const auto t = args.find(L'~');
return t != std::wstring_view::npos &&
(t + 1 == args.size() || til::at(args, t + 1) == L' ');
};
std::wstring_view exePath, arguments;
if (startingDirectory.empty() ||
!_isWslExe(commandLine, exePath, arguments) ||
arguments.find(L"--cd") != std::wstring_view::npos ||
hasBareTilde(arguments))
{
if (startingDirectory.size() > 0 && commandLine.size() >= 3)
{ // "wsl" is three characters; this is a safe bet. no point in doing it if there's no starting directory though!
// Find the first space, quote or the end of the string -- we'll look for wsl before that.
const auto terminator{ commandLine.find_first_of(LR"(" )", 1) }; // look past the first character in case it starts with "
const auto start{ til::at(commandLine, 0) == L'"' ? 1 : 0 };
const std::filesystem::path executablePath{ commandLine.substr(start, terminator - start) };
const auto executableFilename{ executablePath.filename() };
if (executableFilename == L"wsl" || executableFilename == L"wsl.exe")
{
// We've got a WSL -- let's just make sure it's the right one.
if (executablePath.has_parent_path())
{
std::wstring systemDirectory{};
if (FAILED(wil::GetSystemDirectoryW(systemDirectory)))
{
break; // just bail out.
}
// GH#12353: ~ is never a valid Windows path. We can only accept it
// when the exe is wsl.exe. So, we mangle it to %USERPROFILE% here.
return {
std::wstring{ commandLine },
startingDirectory == L"~" ? wil::ExpandEnvironmentStringsW<std::wstring>(L"%USERPROFILE%") :
std::wstring{ startingDirectory }
};
}
if (!til::equals_insensitive_ascii(executablePath.parent_path().native(), systemDirectory))
{
break; // it wasn't in system32!
}
}
else
{
// assume that unqualified WSL is the one in system32 (minor danger)
}
std::wstring dir{ startingDirectory };
if (til::starts_with(dir, L"//wsl$") || til::starts_with(dir, L"//wsl.localhost"))
{
// GH#11994: `wsl --cd` treats forward-slash paths as linux-relative.
// We routinely see these two paths being used but they're actually
// meant as Windows paths, so we convert them to \\ here.
std::ranges::replace(dir, L'/', L'\\');
}
else if (til::starts_with(dir, L"\\\\") && !IsValidDirectory(dir.c_str()))
{
// WSL shells use OSC 7 which uses file URIs. We turn those into UNC paths for
// storage and here we turn them back (\\hostname\bar\baz --> /bar/baz).
// The idea is that any UNC path that doesn't actually exist is likely to be a UNIX path.
// Our OSC 7 parser already rejects lexically invalid paths (via til::is_legal_path).
const auto del = dir.find_first_of(L"\\/", 2);
dir.erase(0, std::min(del, dir.size()));
std::ranges::replace(dir, L'\\', L'/');
}
const auto arguments{ terminator == std::wstring_view::npos ? std::wstring_view{} : commandLine.substr(terminator + 1) };
if (arguments.find(L"--cd") != std::wstring_view::npos)
{
break; // they've already got a --cd!
}
const auto tilde{ arguments.find_first_of(L'~') };
if (tilde != std::wstring_view::npos)
{
if (tilde + 1 == arguments.size() || til::at(arguments, tilde + 1) == L' ')
{
// We want to suppress --cd if they have added a bare ~ to their commandline (they conflict).
break;
}
// Tilde followed by non-space should be okay (like, wsl -d Debian ~/blah.sh)
}
// GH#11994 - If the path starts with //wsl$, then the user is
// likely passing a Windows-style path to the WSL filesystem,
// but with forward slashes instead of backslashes.
// Unfortunately, `wsl --cd` will try to treat this as a
// linux-relative path, which will fail to do the expected
// thing.
//
// In that case, manually mangle the startingDirectory to use
// backslashes as the path separator instead.
std::wstring mangledDirectory{ startingDirectory };
if (til::starts_with(mangledDirectory, L"//wsl$") || til::starts_with(mangledDirectory, L"//wsl.localhost"))
{
mangledDirectory = std::filesystem::path{ startingDirectory }.make_preferred().wstring();
}
return {
fmt::format(FMT_COMPILE(LR"("{}" --cd "{}" {})"), executablePath.native(), mangledDirectory, arguments),
std::wstring{}
};
}
}
} while (false);
// GH #12353: `~` is never a valid windows path. We can only accept that as
// a startingDirectory when the exe is specifically wsl.exe, because that
// can override the real startingDirectory. If the user set the
// startingDirectory to ~, but the commandline to something like pwsh.exe,
// that won't actually work. In that case, mangle the startingDirectory to
// %userprofile%, so it's at least something reasonable.
return {
std::wstring{ commandLine },
startingDirectory == L"~" ? wil::ExpandEnvironmentStringsW<std::wstring>(L"%USERPROFILE%") :
std::wstring{ startingDirectory }
fmt::format(FMT_COMPILE(LR"("{}" --cd "{}" {})"), exePath, dir, arguments),
std::wstring{}
};
}