168 lines
7.0 KiB
Python
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"
|