Files
hermes-agent/tests/pm/test_long_paths_windows_live.py
2026-10-09 17:31:52 +02:00

168 lines
7.0 KiB
Python

"""Live Windows proof for PM under ``LongPathsEnabled = 0`` (#130242, #130232).
Runs ONLY on a real Windows host whose registry value
``HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem\\LongPathsEnabled`` is 0
(the Windows default). The on-demand ``wine2e/**`` lane
(``.github/workflows/windows-venv-e2e.yml``) sets it in an earlier step, so this
test's process starts with long paths off, and selects the file with
``-m integration`` (the marker keeps it out of the ordinary OS lane, whose runner
has long paths on and could not make the claim).
Nothing is mocked: a ``Store`` rooted under a temp directory drives the real
scratch / copy / digest / publish / removal path a bundled-copy install takes
(``pm.install._copy_verified_source`` plus ``_install``), over a tree whose files
and one directory lie beyond MAX_PATH. The test reaches its own fixtures through
``\\\\?\\`` spellings it builds itself, so a check never trusts the very calls
under test (plain ``Path.exists`` silently answers False beyond 260 characters).
"""
from __future__ import annotations
import json
import ntpath
import os
import shutil
import sys
from pathlib import Path
import pytest
pytestmark = [
pytest.mark.platforms("windows"), # live long-path behaviour exists only on Windows
# Requires a host configured with long paths off; the wine2e lane selects it.
pytest.mark.integration,
]
MAX_PATH = 260
VERBATIM = "\\\\?\\"
SEGMENT = "s" * 60
def _long_paths_enabled() -> bool:
import ctypes
ntdll = ctypes.WinDLL("ntdll")
ntdll.RtlAreLongPathsEnabled.restype = ctypes.c_ubyte
return bool(ntdll.RtlAreLongPathsEnabled())
def _verbatim(path: Path | str) -> Path:
"""The test's own independent spelling for checks beyond MAX_PATH."""
text = ntpath.abspath(os.fspath(path))
return Path(text if text.startswith(VERBATIM) else VERBATIM + text)
def _plain(path: Path | str) -> str:
text = os.fspath(path)
return text.removeprefix(VERBATIM)
def _strings(value):
if isinstance(value, str):
yield value
elif isinstance(value, dict):
for key, item in value.items():
yield key
yield from _strings(item)
elif isinstance(value, list):
for item in value:
yield from _strings(item)
def _build_tree(entry: Path, plain_base_length: int) -> dict[str, bytes]:
"""Files beyond MAX_PATH, one directory beyond MAX_PATH, and a short binary.
``plain_base_length`` is the plain length of the shortest root the tree
will sit under, so every deep path exceeds MAX_PATH wherever it lands.
"""
deep = ""
while plain_base_length + len(deep) <= MAX_PATH + 10:
deep = f"{deep}/{SEGMENT}" if deep else SEGMENT
files = {
"bin/tool.exe": b"not a real binary",
"short.txt": b"short",
# File whose full path crosses MAX_PATH inside a directory that does not.
f"{deep.rsplit('/', 1)[0]}/{'f' * 80}.txt": b"file beyond MAX_PATH",
# Directory whose own path is beyond MAX_PATH, holding two files.
f"{deep}/inner/a.txt": b"inside a directory beyond MAX_PATH",
f"{deep}/inner/b.py": b"print('deep')\n",
}
for rel, data in files.items():
target = _verbatim(entry) / Path(rel)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return files
def _assert_tree(entry: Path, files: dict[str, bytes]) -> None:
root = _verbatim(entry)
for rel, data in files.items():
assert (root / Path(rel)).read_bytes() == data, rel
found = sorted(p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file())
assert found == sorted(files)
def test_store_lifecycle_beyond_max_path_with_long_paths_disabled(tmp_path):
# A green run must not be a false proof: long paths really are off here.
assert sys.platform == "win32"
assert not _long_paths_enabled(), (
"LongPathsEnabled is on for this process; set HKLM\\SYSTEM\\CurrentControlSet\\"
"Control\\FileSystem LongPathsEnabled=0 before the interpreter starts")
probe = _verbatim(tmp_path) / ("p" * 120) / ("q" * 120) / "probe.txt"
probe.parent.mkdir(parents=True)
probe.write_bytes(b"x")
assert len(_plain(probe)) > MAX_PATH
with pytest.raises(OSError):
os.stat(_plain(probe)) # the host really cuts plain spellings at MAX_PATH
shutil.rmtree(_verbatim(tmp_path) / ("p" * 120))
from pm.install import _install, _remove_entry
from pm.lock import Facts, Lockfile
from pm.package import Package, compose_env
from pm.store import Store, current_target, tree_digest
class Tool(Package):
name = "longpath-probe"
def binary(self, entry: Path, target: str) -> Path:
return entry / "bin" / "tool.exe"
package, target, version, pin = Tool(), current_target(), "1.0.0", "0" * 64
entry_name = package.store_entry(version, target)
source_root, store_root = tmp_path / "src", tmp_path / "dst"
source, store = Store(source_root), Store(store_root)
lockfile = Lockfile(tmp_path / "lock.json")
lockfile.set_pin(package.name, version, {target: {"sha256": pin}})
files = _build_tree(source_root / entry_name,
len(_plain(tmp_path / "dst" / entry_name)))
expected = tree_digest(_verbatim(source_root / entry_name))
# The bundled source as a sealed install records it: facts.json inside its store.
source_facts = Facts(source_root / "facts.json")
source_facts.record(package.name, version, entry_name, package.env(source.entry(entry_name), target),
source.root, target=target, artifacts=[pin], digest=expected)
# The real bundled-copy install: _copy_verified_source, verification, publish, facts commit.
facts = Facts(store_root / "facts.json")
published = _install(package, lockfile, facts, store, target, copy_from=(source_facts, source))
assert not [p for p in _verbatim(store_root).iterdir() if p.name.startswith(".staging-")]
_assert_tree(store_root / entry_name, files)
assert tree_digest(published) == expected
# Bytes scratch() must clean up itself (an abandoned extraction).
with store.scratch() as scratch:
shutil.copytree(source.entry(entry_name), scratch / "abandoned", symlinks=True)
assert not _verbatim(scratch).exists(), "scratch() left bytes behind beyond MAX_PATH"
# Records and child environments are exits: they carry the ordinary spelling.
assert facts.installed(package.name, version, store.root, (target, (pin,)))
on_disk = json.loads((store_root / "facts.json").read_text(encoding="utf-8"))
assert not [s for s in _strings(on_disk) if VERBATIM in s]
child_env = compose_env([facts.env_for(package.name, store.root)], base={})
assert not [v for v in child_env.values() if VERBATIM in v], child_env
assert ntpath.normcase(child_env["PATH"]) == ntpath.normcase(
_plain(store_root / entry_name / "bin"))
_remove_entry(store, entry_name)
assert not _verbatim(store_root / entry_name).exists(), "removal left bytes beyond MAX_PATH"