Skip to content
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
38 changes: 37 additions & 1 deletion src/skillspector/input_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,41 @@ def _windows_last_error() -> OSError:
return cast(OSError, ctypes.WinError(ctypes.get_last_error())) # type: ignore[attr-defined]


def _windows_long_path_name(path: str) -> str:
"""Expand any 8.3 short components of a Windows path to their long form.

``GetFinalPathNameByHandleW`` always answers with long components, while the
requested path may carry short ones: Windows keeps an 8.3 alias for a
directory whose name holds a space, so a profile directory such as
``C:\\Users\\Hoang Pham`` reaches the scanner as ``C:\\Users\\HOANGP~1`` by way
of ``%TEMP%``. Comparing the two spellings without expanding them first
rejects every file below such a path.

The short name is an alias the filesystem keeps for one directory entry, so
expanding it names that same entry and does not resolve symlinks or
junctions; the reparse-point checks around the caller keep their meaning. A
path that no longer resolves comes back unchanged, which leaves that caller
fail-closed.
"""
import ctypes
from ctypes import wintypes

kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
get_long_path_name = kernel32.GetLongPathNameW
get_long_path_name.argtypes = [wintypes.LPCWSTR, wintypes.LPWSTR, wintypes.DWORD]
get_long_path_name.restype = wintypes.DWORD

buffer_size = 260
while True:
buffer = ctypes.create_unicode_buffer(buffer_size)
result = cast(int, get_long_path_name(path, buffer, buffer_size))
if result == 0:
return path
if result < buffer_size:
return buffer.value
buffer_size = result + 1


def _windows_normalized_path(path: str) -> str:
"""Normalize a Windows DOS path for an exact opened-handle comparison."""
long_path_prefix = "\\\\?\\"
Expand All @@ -451,7 +486,8 @@ def _windows_normalized_path(path: str) -> str:
path = "\\\\" + path[len(long_unc_prefix) :]
elif path.startswith(long_path_prefix):
path = path[len(long_path_prefix) :]
return os.path.normcase(os.path.normpath(os.path.abspath(path)))
absolute = os.path.normpath(os.path.abspath(path))
return os.path.normcase(_windows_long_path_name(absolute))


def _close_fd_safely(fd: int) -> None:
Expand Down
44 changes: 43 additions & 1 deletion tests/unit/test_input_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,13 @@ def _mock_windows_secure_open(
handle: int = 1,
attributes: int = 0,
final_path: str | None = None,
long_names: dict[str, str] | None = None,
) -> None:
"""Install a handle-level Windows open simulation on any platform."""
"""Install a handle-level Windows open simulation on any platform.

``long_names`` stands in for ``GetLongPathNameW``: it maps a path spelled
with an 8.3 short component to the long spelling the filesystem aliases.
"""

def get_file_information(_handle: int, information: object) -> bool:
information._obj.dwFileAttributes = attributes # type: ignore[attr-defined]
Expand All @@ -52,10 +57,16 @@ def get_final_path(_handle: int, buffer: object, _size: int, _flags: int) -> int
buffer.value = opened_path # type: ignore[attr-defined]
return len(opened_path)

def get_long_path_name(path: str, buffer: object, _size: int) -> int:
expanded = (long_names or {}).get(path, path)
buffer.value = expanded # type: ignore[attr-defined]
return len(expanded)

kernel32 = SimpleNamespace(
CreateFileW=lambda *_args: handle,
GetFileInformationByHandle=get_file_information,
GetFinalPathNameByHandleW=get_final_path,
GetLongPathNameW=get_long_path_name,
CloseHandle=lambda _handle: True,
)
msvcrt = SimpleNamespace(open_osfhandle=lambda _handle, _flags: os.open(source, os.O_RDONLY))
Expand Down Expand Up @@ -307,6 +318,37 @@ def test_windows_no_follow_open_rejects_reparse_point(
_open_regular_file_from_windows_handle(source)


def test_windows_no_follow_open_accepts_a_short_dos_name(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A path spelled with an 8.3 short component opens the entry it aliases."""
source = tmp_path / "SKILL.md"
source.write_text("# Skill", encoding="utf-8")
short = tmp_path / "SHORTN~1.MD"
_mock_windows_secure_open(
monkeypatch,
source,
final_path=str(source),
long_names={str(short): str(source)},
)

with _open_regular_file_from_windows_handle(short) as opened:
assert opened.read() == b"# Skill"


def test_windows_no_follow_open_rejects_an_unresolvable_short_name(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A short name that no longer expands leaves the comparison fail-closed."""
source = tmp_path / "SKILL.md"
source.write_text("# Skill", encoding="utf-8")
short = tmp_path / "SHORTN~1.MD"
_mock_windows_secure_open(monkeypatch, source, final_path=str(source))

with pytest.raises(ValueError, match="Could not safely open"):
_open_regular_file_from_windows_handle(short)


def test_windows_no_follow_open_rejects_canonical_path_mismatch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down
Loading