Skip to content

Commit d6fe7dd

Browse files
Евгений БлиновЕвгений Блинов
authored andcommitted
Add Windows-specific file handle helper for testing sharing violations
1 parent 894b8a5 commit d6fe7dd

3 files changed

Lines changed: 162 additions & 1 deletion

File tree

tests/helpers.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,56 @@
1+
from contextlib import ExitStack, contextmanager
12
from io import BytesIO
23
from pathlib import Path
4+
from sys import platform
35
from tarfile import TarInfo
46
from tarfile import open as open_tar
57
from typing import Dict, Iterable, Optional
68

79
from dirstree import Crawler
810
from emptylog.call_data import LoggerCallData
911

12+
if platform == 'win32':
13+
from ctypes import ( # type: ignore[attr-defined] # POSIX typeshed omits the Windows-only API.
14+
WinDLL,
15+
c_uint32,
16+
c_void_p,
17+
c_wchar_p,
18+
get_last_error,
19+
)
20+
21+
22+
@contextmanager
23+
def hold_windows_path_open(path: Path, *, share_mode: int, flags: int):
24+
"""
25+
Keep a Windows filesystem object open with explicit sharing permissions.
26+
27+
This helper is called only by Windows-only tests. It uses the native API
28+
because Python's regular ``open`` does not let tests choose delete sharing.
29+
"""
30+
if platform != 'win32':
31+
raise RuntimeError('Windows handles can only be held open on Windows.')
32+
33+
kernel32 = WinDLL('kernel32', use_last_error=True)
34+
create_file = kernel32.CreateFileW
35+
create_file.argtypes = [c_wchar_p, c_uint32, c_uint32, c_void_p, c_uint32, c_uint32, c_void_p]
36+
create_file.restype = c_void_p
37+
close_handle = kernel32.CloseHandle
38+
close_handle.argtypes = [c_void_p]
39+
close_handle.restype = c_uint32
40+
41+
generic_read = 0x80000000
42+
open_existing = 3
43+
invalid_handle = c_void_p(-1).value
44+
handle = create_file(str(path), generic_read, share_mode, None, open_existing, flags, None)
45+
46+
if handle == invalid_handle:
47+
raise OSError(get_last_error(), f'Could not open Windows lock target: {path}')
48+
49+
with ExitStack() as resources:
50+
resources.callback(close_handle, handle)
51+
52+
yield
53+
1054

1155
def assert_any_message_contains(calls: Iterable[LoggerCallData], *chunks: str) -> None:
1256
"""Assert that at least one logged message contains all chunks, case-insensitively."""

tests/plugins/test_directory_isolate.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
from tests.helpers import (
2323
assert_any_message_contains,
24+
hold_windows_path_open,
2425
make_tar_bytes,
2526
read_tree,
2627
)
@@ -42,6 +43,10 @@
4243
)
4344

4445
VENV_PYTHON_RELATIVE_PATH = Path('Scripts') / 'python.exe' if os_name == 'nt' else Path('bin') / 'python'
46+
WINDOWS_FILE_ATTRIBUTE_NORMAL = 0x00000080
47+
WINDOWS_FILE_SHARE_READ = 0x00000001
48+
WINDOWS_FILE_SHARE_WRITE = 0x00000002
49+
WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process'
4550

4651

4752
class TemporaryIsolateFactory(Protocol):
@@ -2397,6 +2402,36 @@ def test_load_permission_denied_write(request, temporary_isolate):
23972402
assert_any_message_contains(logger.data.exception, 'archive', 'failed')
23982403

23992404

2405+
@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX')
2406+
def test_load_windows_locked_file_reports_backup_failure_and_retains_old_data(temporary_isolate):
2407+
"""
2408+
Verify that a native Windows commit failure logs an archive failure and preserves live data.
2409+
2410+
A readable handle that denies delete sharing lets rollback copy the existing
2411+
file into backup before deletion fails, exercising the common rollback path
2412+
that must ignore the incomplete backup copy.
2413+
"""
2414+
isolate = temporary_isolate()
2415+
existing_file = isolate.directory / 'old.txt'
2416+
existing_file.write_text('old')
2417+
logger = MemoryLogger()
2418+
2419+
with hold_windows_path_open(
2420+
existing_file,
2421+
share_mode=WINDOWS_FILE_SHARE_READ | WINDOWS_FILE_SHARE_WRITE,
2422+
flags=WINDOWS_FILE_ATTRIBUTE_NORMAL,
2423+
), pytest.raises(
2424+
ArchiveUnpackError,
2425+
match=match(f'Archive commit failed; rollback attempted. Cause: {WINDOWS_SHARING_VIOLATION_REASON}.'),
2426+
) as raised:
2427+
isolate.load(make_tar_bytes({'new.txt': b'new'}), logger=logger)
2428+
2429+
assert 'Unrestored paths:' not in str(raised.value)
2430+
assert existing_file.read_text() == 'old'
2431+
assert not (isolate.directory / 'new.txt').exists()
2432+
assert_any_message_contains(logger.data.exception, 'archive', 'failed')
2433+
2434+
24002435
@pytest.mark.skipif(os_name == 'nt', reason='permission mode semantics differ on Windows')
24012436
def test_dump_permission_denied_read(request, temporary_isolate):
24022437
"""Verify that a read failure during dump propagates the read error and logs the failure."""
@@ -2412,3 +2447,25 @@ def test_dump_permission_denied_read(request, temporary_isolate):
24122447
isolate.dump(logger=logger)
24132448

24142449
assert_any_message_contains(logger.data.exception, 'dump', 'failed')
2450+
2451+
2452+
@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX')
2453+
def test_dump_windows_locked_file_propagates_read_failure_and_logs_failure(temporary_isolate):
2454+
"""
2455+
Verify that a native Windows read failure during dump is propagated and logged.
2456+
2457+
An exclusive native handle prevents the archive operation from reading a
2458+
regular isolate file, so dump must fail instead of returning partial bytes.
2459+
"""
2460+
isolate = temporary_isolate()
2461+
unreadable = isolate.directory / 'unreadable.txt'
2462+
unreadable.write_text('secret')
2463+
logger = MemoryLogger()
2464+
2465+
with hold_windows_path_open(unreadable, share_mode=0, flags=WINDOWS_FILE_ATTRIBUTE_NORMAL), pytest.raises(
2466+
PermissionError,
2467+
match=match(f"[Errno 13] Permission denied: '{unreadable}'"),
2468+
):
2469+
isolate.dump(logger=logger)
2470+
2471+
assert_any_message_contains(logger.data.exception, 'dump', 'failed')

tests/plugins/test_temporary_directory_throng.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from locklib import LockTraceWrapper
1616
from suby.subprocess_result import SubprocessResult
1717

18-
from tests.helpers import make_tar_bytes
18+
from tests.helpers import hold_windows_path_open, make_tar_bytes
1919
from throng import (
2020
InvalidBaseDirectoryError,
2121
IsolateDeletedError,
@@ -28,6 +28,11 @@
2828
TemporaryDirectoryThrong,
2929
)
3030

31+
WINDOWS_DIRECTORY_HANDLE_FLAGS = 0x02000000
32+
WINDOWS_FILE_SHARE_READ = 0x00000001
33+
WINDOWS_FILE_SHARE_WRITE = 0x00000002
34+
WINDOWS_SHARING_VIOLATION_REASON = 'The process cannot access the file because it is being used by another process'
35+
3136

3237
def assert_isolate_deleted(operation):
3338
with pytest.raises(IsolateDeletedError, match=match('Isolate has been deleted.')):
@@ -136,6 +141,29 @@ def test_temp_base_not_writable(tmp_path, request):
136141
]
137142

138143

144+
@pytest.mark.skipif(os_name != 'nt', reason='Windows read-only directory attributes do not apply on POSIX')
145+
def test_temp_base_read_only_on_windows_is_rejected_and_logged(tmp_path, request):
146+
"""
147+
Verify that a Windows read-only temporary base directory is rejected and logged.
148+
149+
Setting the Windows read-only attribute makes the configured base fail the
150+
plugin's ``W_OK`` check, so no temporary isolate may be created inside it.
151+
"""
152+
base_directory = tmp_path / 'base'
153+
base_directory.mkdir()
154+
request.addfinalizer(lambda: base_directory.chmod(S_IREAD | S_IWRITE | S_IEXEC))
155+
base_directory.chmod(S_IREAD)
156+
config = TemporaryDirectoryIsolationConfig(base_directory=str(base_directory))
157+
logger = MemoryLogger()
158+
159+
with pytest.raises(InvalidBaseDirectoryError, match=match(f'Temporary base directory is not writable: {base_directory}')):
160+
TemporaryDirectoryThrong(logger=logger, config=config).get_isolate()
161+
162+
assert [str(call.message) for call in logger.data.error] == [
163+
f'Temporary base directory is not writable: {base_directory}',
164+
]
165+
166+
139167
def test_temp_isolates_are_distinct(tmp_path):
140168
"""Verify that temporary isolates get separate directories and do not share files."""
141169
config = TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path))
@@ -373,6 +401,38 @@ def test_temp_delete_failure_raises_and_does_not_log_success(tmp_path, request):
373401
assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1
374402

375403

404+
@pytest.mark.skipif(os_name != 'nt', reason='Windows sharing violations are not available on POSIX')
405+
def test_temp_delete_locked_windows_directory_raises_and_can_be_retried(tmp_path):
406+
"""
407+
Verify that a native Windows delete failure is logged and leaves deletion retryable.
408+
409+
An open directory handle without delete sharing blocks the first cleanup;
410+
after the handle closes, the same isolate must be deletable successfully.
411+
"""
412+
logger = MemoryLogger()
413+
throng = TemporaryDirectoryThrong(logger=logger, config=TemporaryDirectoryIsolationConfig(base_directory=str(tmp_path)))
414+
isolate = throng.get_isolate()
415+
isolate_directory = isolate.directory
416+
denied_path = isolate_directory if version_info >= (3, 12) else str(isolate_directory)
417+
permission_error_message = f'[WinError 32] {WINDOWS_SHARING_VIOLATION_REASON}: {denied_path!r}'
418+
419+
with hold_windows_path_open(
420+
isolate_directory,
421+
share_mode=WINDOWS_FILE_SHARE_READ | WINDOWS_FILE_SHARE_WRITE,
422+
flags=WINDOWS_DIRECTORY_HANDLE_FLAGS,
423+
), pytest.raises(PermissionError, match=match(permission_error_message)):
424+
isolate.delete()
425+
426+
assert isolate_directory.exists()
427+
assert any('Delete failed' in str(call.message) for call in logger.data.exception)
428+
assert all(str(call.message) != 'Delete completed successfully.' for call in logger.data.info)
429+
430+
isolate.delete()
431+
432+
assert not isolate_directory.exists()
433+
assert [str(call.message) for call in logger.data.info].count('Delete completed successfully.') == 1
434+
435+
376436
def test_temp_delete_after_delete_raises(tmp_path):
377437
"""Verify that a second delete call is rejected and logged as a delete operation."""
378438
logger = MemoryLogger()

0 commit comments

Comments
 (0)