|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import re |
| 6 | +from collections.abc import Mapping |
| 7 | +from pathlib import Path |
6 | 8 | from typing import Any |
7 | 9 |
|
8 | 10 |
|
@@ -33,3 +35,67 @@ def validate_artifact_component(value: Any, *, context: str) -> str: |
33 | 35 | if windows_stem in WINDOWS_RESERVED_NAMES: |
34 | 36 | raise ValueError(f"unsafe {context}: {value!r} is reserved on Windows") |
35 | 37 | return value |
| 38 | + |
| 39 | + |
| 40 | +def validate_distinct_file_paths( |
| 41 | + paths: Mapping[str, str | Path], |
| 42 | + *, |
| 43 | + context: str, |
| 44 | +) -> None: |
| 45 | + """Reject aliases using portable case-folding and physical file identity.""" |
| 46 | + |
| 47 | + resolved_keys: dict[str, str] = {} |
| 48 | + physical_keys: dict[tuple[int, int], str] = {} |
| 49 | + observed_paths: list[tuple[str, Path, bool]] = [] |
| 50 | + for label, raw_path in paths.items(): |
| 51 | + path = Path(raw_path) |
| 52 | + try: |
| 53 | + resolved = path.resolve(strict=False) |
| 54 | + except OSError as error: |
| 55 | + raise ValueError(f"{context} {label!r} cannot be resolved: {error}") from error |
| 56 | + |
| 57 | + portable_key = str(resolved).casefold() |
| 58 | + previous_label = resolved_keys.get(portable_key) |
| 59 | + if previous_label is not None: |
| 60 | + raise ValueError( |
| 61 | + f"{context} must be different physical files; " |
| 62 | + f"{previous_label!r} and {label!r} collide case-insensitively" |
| 63 | + ) |
| 64 | + |
| 65 | + try: |
| 66 | + metadata = path.stat() |
| 67 | + except (FileNotFoundError, NotADirectoryError): |
| 68 | + metadata = None |
| 69 | + except OSError as error: |
| 70 | + raise ValueError(f"{context} {label!r} is unavailable: {error}") from error |
| 71 | + |
| 72 | + physical_key = ( |
| 73 | + (int(metadata.st_dev), int(metadata.st_ino)) |
| 74 | + if metadata is not None and int(metadata.st_ino) != 0 |
| 75 | + else None |
| 76 | + ) |
| 77 | + if physical_key is not None and physical_key in physical_keys: |
| 78 | + raise ValueError( |
| 79 | + f"{context} must be different physical files; " |
| 80 | + f"{physical_keys[physical_key]!r} and {label!r} are aliases" |
| 81 | + ) |
| 82 | + |
| 83 | + for observed_label, observed_path, observed_exists in observed_paths: |
| 84 | + if metadata is None or not observed_exists: |
| 85 | + continue |
| 86 | + try: |
| 87 | + same_file = path.samefile(observed_path) |
| 88 | + except (FileNotFoundError, NotADirectoryError): |
| 89 | + same_file = False |
| 90 | + except OSError as error: |
| 91 | + raise ValueError(f"{context} {label!r} is unavailable: {error}") from error |
| 92 | + if same_file: |
| 93 | + raise ValueError( |
| 94 | + f"{context} must be different physical files; " |
| 95 | + f"{observed_label!r} and {label!r} are aliases" |
| 96 | + ) |
| 97 | + |
| 98 | + resolved_keys[portable_key] = label |
| 99 | + if physical_key is not None: |
| 100 | + physical_keys[physical_key] = label |
| 101 | + observed_paths.append((label, path, metadata is not None)) |
0 commit comments