diff --git a/src/skillspector/input_handler.py b/src/skillspector/input_handler.py index 6468bbf3..04a72f07 100644 --- a/src/skillspector/input_handler.py +++ b/src/skillspector/input_handler.py @@ -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 = "\\\\?\\" @@ -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: diff --git a/tests/unit/test_input_handler.py b/tests/unit/test_input_handler.py index ec52b678..bce9b243 100644 --- a/tests/unit/test_input_handler.py +++ b/tests/unit/test_input_handler.py @@ -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] @@ -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)) @@ -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: