vcti-path 1.0.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vcti_path-1.0.3/LICENSE +8 -0
- vcti_path-1.0.3/PKG-INFO +131 -0
- vcti_path-1.0.3/README.md +116 -0
- vcti_path-1.0.3/pyproject.toml +35 -0
- vcti_path-1.0.3/setup.cfg +4 -0
- vcti_path-1.0.3/src/vcti/path/__init__.py +25 -0
- vcti_path-1.0.3/src/vcti/path/file_id_utils.py +160 -0
- vcti_path-1.0.3/src/vcti/path/filename_validator.py +69 -0
- vcti_path-1.0.3/src/vcti/path/path_utils.py +111 -0
- vcti_path-1.0.3/src/vcti/path/py.typed +0 -0
- vcti_path-1.0.3/src/vcti_path.egg-info/PKG-INFO +131 -0
- vcti_path-1.0.3/src/vcti_path.egg-info/SOURCES.txt +17 -0
- vcti_path-1.0.3/src/vcti_path.egg-info/dependency_links.txt +1 -0
- vcti_path-1.0.3/src/vcti_path.egg-info/requires.txt +7 -0
- vcti_path-1.0.3/src/vcti_path.egg-info/top_level.txt +1 -0
- vcti_path-1.0.3/src/vcti_path.egg-info/zip-safe +1 -0
- vcti_path-1.0.3/tests/test_file_id_utils.py +158 -0
- vcti_path-1.0.3/tests/test_filename_validator.py +101 -0
- vcti_path-1.0.3/tests/test_path_utils.py +193 -0
vcti_path-1.0.3/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Copyright (c) 2018-2026 Visual Collaboration Technologies Inc.
|
|
2
|
+
All Rights Reserved.
|
|
3
|
+
|
|
4
|
+
This software is proprietary and confidential. Unauthorized copying,
|
|
5
|
+
distribution, or use of this software, via any medium, is strictly
|
|
6
|
+
prohibited. Access is granted only to authorized VCollab developers
|
|
7
|
+
and individuals explicitly authorized by Visual Collaboration
|
|
8
|
+
Technologies Inc.
|
vcti_path-1.0.3/PKG-INFO
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-path
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Safe path handling for VCollab applications
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
Requires-Python: <3.15,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest; extra == "test"
|
|
11
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
12
|
+
Provides-Extra: lint
|
|
13
|
+
Requires-Dist: ruff; extra == "lint"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Path Utilities
|
|
17
|
+
|
|
18
|
+
## Purpose
|
|
19
|
+
|
|
20
|
+
Safe path handling for VCollab applications -- filename validation, file
|
|
21
|
+
identity (POSIX-style relative paths as stable IDs), and path resolution
|
|
22
|
+
with access validation.
|
|
23
|
+
|
|
24
|
+
This package has **zero external dependencies**.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install vcti-path
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### In `requirements.txt`
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
vcti-path>=1.0.3
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### In `pyproject.toml` dependencies
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
dependencies = [
|
|
44
|
+
"vcti-path>=1.0.3",
|
|
45
|
+
]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
### Validate filenames
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from vcti.path import FileNameValidator
|
|
56
|
+
|
|
57
|
+
FileNameValidator.is_valid("report.pdf") # True
|
|
58
|
+
FileNameValidator.is_valid("bad|name") # False
|
|
59
|
+
FileNameValidator.is_valid("..") # False
|
|
60
|
+
|
|
61
|
+
FileNameValidator.validate("data.json") # OK
|
|
62
|
+
FileNameValidator.validate("bad/name") # Raises ValueError
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### File IDs -- stable POSIX-style identifiers
|
|
66
|
+
|
|
67
|
+
A file ID is a POSIX-format relative path that uniquely identifies a
|
|
68
|
+
file or directory within a base directory. No leading `/`, `./`, or `../`.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
from vcti.path import FileId
|
|
73
|
+
|
|
74
|
+
# Validate
|
|
75
|
+
FileId.is_valid("scripts/setup.sh") # True
|
|
76
|
+
FileId.is_valid("/absolute/path") # False
|
|
77
|
+
FileId.is_valid("../escape") # False
|
|
78
|
+
|
|
79
|
+
# Resolve to filesystem path
|
|
80
|
+
base = Path("/project")
|
|
81
|
+
FileId.resolve_path("src/main.py", base)
|
|
82
|
+
# -> Path("/project/src/main.py")
|
|
83
|
+
|
|
84
|
+
# Extract file ID from a path
|
|
85
|
+
src = Path("/project/src/main.py")
|
|
86
|
+
FileId.get_file_id(src, base)
|
|
87
|
+
# -> "src/main.py"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Path resolution and access validation
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from vcti.path import abs_path, validate_file_access, resolve_path
|
|
94
|
+
|
|
95
|
+
# Absolute path resolution
|
|
96
|
+
abs_path("relative/file.txt")
|
|
97
|
+
# -> Path("<cwd>/relative/file.txt")
|
|
98
|
+
|
|
99
|
+
# Validate that a file exists and is readable
|
|
100
|
+
validate_file_access("config.yaml")
|
|
101
|
+
# Raises FileNotFoundError, IsADirectoryError, or PermissionError
|
|
102
|
+
|
|
103
|
+
# Resolve relative to a base directory
|
|
104
|
+
resolve_path("data/input.csv", "/project")
|
|
105
|
+
# -> Path("/project/data/input.csv")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Public API
|
|
111
|
+
|
|
112
|
+
| Symbol | Type | Purpose |
|
|
113
|
+
|--------|------|---------|
|
|
114
|
+
| `FileNameValidator.is_valid(name)` | classmethod | Check if a filename is valid |
|
|
115
|
+
| `FileNameValidator.validate(name)` | classmethod | Validate filename, raise `ValueError` if invalid |
|
|
116
|
+
| `FileId.is_valid(file_id)` | staticmethod | Check if a POSIX file ID is valid |
|
|
117
|
+
| `FileId.validate(file_id)` | staticmethod | Validate file ID, raise `ValueError` if invalid |
|
|
118
|
+
| `FileId.resolve_path(file_id, base_dir)` | staticmethod | Resolve file ID to absolute path |
|
|
119
|
+
| `FileId.get_file_id(file_path, base_dir)` | staticmethod | Extract file ID from absolute path |
|
|
120
|
+
| `abs_path(fp)` | function | Convert to absolute resolved `Path` |
|
|
121
|
+
| `validate_file_access(fp)` | function | Validate file exists and is readable |
|
|
122
|
+
| `validate_folder_access(fp)` | function | Validate directory exists |
|
|
123
|
+
| `resolve_path(path, base_dir)` | function | Resolve path relative to base directory |
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
|
|
129
|
+
- [Design](docs/design.md) -- Concepts, architecture decisions, and rationale
|
|
130
|
+
- [Source Guide](docs/source-guide.md) -- File descriptions and execution flow traces
|
|
131
|
+
- [API Reference](docs/api.md) -- Autodoc for all modules
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Path Utilities
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Safe path handling for VCollab applications -- filename validation, file
|
|
6
|
+
identity (POSIX-style relative paths as stable IDs), and path resolution
|
|
7
|
+
with access validation.
|
|
8
|
+
|
|
9
|
+
This package has **zero external dependencies**.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install vcti-path
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### In `requirements.txt`
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
vcti-path>=1.0.3
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### In `pyproject.toml` dependencies
|
|
26
|
+
|
|
27
|
+
```toml
|
|
28
|
+
dependencies = [
|
|
29
|
+
"vcti-path>=1.0.3",
|
|
30
|
+
]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
### Validate filenames
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from vcti.path import FileNameValidator
|
|
41
|
+
|
|
42
|
+
FileNameValidator.is_valid("report.pdf") # True
|
|
43
|
+
FileNameValidator.is_valid("bad|name") # False
|
|
44
|
+
FileNameValidator.is_valid("..") # False
|
|
45
|
+
|
|
46
|
+
FileNameValidator.validate("data.json") # OK
|
|
47
|
+
FileNameValidator.validate("bad/name") # Raises ValueError
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### File IDs -- stable POSIX-style identifiers
|
|
51
|
+
|
|
52
|
+
A file ID is a POSIX-format relative path that uniquely identifies a
|
|
53
|
+
file or directory within a base directory. No leading `/`, `./`, or `../`.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from vcti.path import FileId
|
|
58
|
+
|
|
59
|
+
# Validate
|
|
60
|
+
FileId.is_valid("scripts/setup.sh") # True
|
|
61
|
+
FileId.is_valid("/absolute/path") # False
|
|
62
|
+
FileId.is_valid("../escape") # False
|
|
63
|
+
|
|
64
|
+
# Resolve to filesystem path
|
|
65
|
+
base = Path("/project")
|
|
66
|
+
FileId.resolve_path("src/main.py", base)
|
|
67
|
+
# -> Path("/project/src/main.py")
|
|
68
|
+
|
|
69
|
+
# Extract file ID from a path
|
|
70
|
+
src = Path("/project/src/main.py")
|
|
71
|
+
FileId.get_file_id(src, base)
|
|
72
|
+
# -> "src/main.py"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Path resolution and access validation
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from vcti.path import abs_path, validate_file_access, resolve_path
|
|
79
|
+
|
|
80
|
+
# Absolute path resolution
|
|
81
|
+
abs_path("relative/file.txt")
|
|
82
|
+
# -> Path("<cwd>/relative/file.txt")
|
|
83
|
+
|
|
84
|
+
# Validate that a file exists and is readable
|
|
85
|
+
validate_file_access("config.yaml")
|
|
86
|
+
# Raises FileNotFoundError, IsADirectoryError, or PermissionError
|
|
87
|
+
|
|
88
|
+
# Resolve relative to a base directory
|
|
89
|
+
resolve_path("data/input.csv", "/project")
|
|
90
|
+
# -> Path("/project/data/input.csv")
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## Public API
|
|
96
|
+
|
|
97
|
+
| Symbol | Type | Purpose |
|
|
98
|
+
|--------|------|---------|
|
|
99
|
+
| `FileNameValidator.is_valid(name)` | classmethod | Check if a filename is valid |
|
|
100
|
+
| `FileNameValidator.validate(name)` | classmethod | Validate filename, raise `ValueError` if invalid |
|
|
101
|
+
| `FileId.is_valid(file_id)` | staticmethod | Check if a POSIX file ID is valid |
|
|
102
|
+
| `FileId.validate(file_id)` | staticmethod | Validate file ID, raise `ValueError` if invalid |
|
|
103
|
+
| `FileId.resolve_path(file_id, base_dir)` | staticmethod | Resolve file ID to absolute path |
|
|
104
|
+
| `FileId.get_file_id(file_path, base_dir)` | staticmethod | Extract file ID from absolute path |
|
|
105
|
+
| `abs_path(fp)` | function | Convert to absolute resolved `Path` |
|
|
106
|
+
| `validate_file_access(fp)` | function | Validate file exists and is readable |
|
|
107
|
+
| `validate_folder_access(fp)` | function | Validate directory exists |
|
|
108
|
+
| `resolve_path(path, base_dir)` | function | Resolve path relative to base directory |
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Documentation
|
|
113
|
+
|
|
114
|
+
- [Design](docs/design.md) -- Concepts, architecture decisions, and rationale
|
|
115
|
+
- [Source Guide](docs/source-guide.md) -- File descriptions and execution flow traces
|
|
116
|
+
- [API Reference](docs/api.md) -- Autodoc for all modules
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vcti-path"
|
|
7
|
+
version = "1.0.3"
|
|
8
|
+
description = "Safe path handling for VCollab applications"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
authors = [
|
|
11
|
+
{name = "Visual Collaboration Technologies Inc."}
|
|
12
|
+
]
|
|
13
|
+
requires-python = ">=3.12,<3.15"
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
test = ["pytest", "pytest-cov"]
|
|
18
|
+
lint = ["ruff"]
|
|
19
|
+
|
|
20
|
+
[tool.setuptools.packages.find]
|
|
21
|
+
where = ["src"]
|
|
22
|
+
include = ["vcti.path", "vcti.path.*"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
zip-safe = true
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
addopts = "--cov=vcti.path --cov-report=term-missing --cov-fail-under=95"
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
target-version = "py312"
|
|
32
|
+
line-length = 99
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint]
|
|
35
|
+
select = ["E", "F", "W", "I", "UP"]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Safe path handling for VCollab applications.
|
|
4
|
+
|
|
5
|
+
Provides filename validation, file identity (POSIX-style relative paths
|
|
6
|
+
as stable IDs), and path resolution with access validation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from importlib.metadata import version
|
|
10
|
+
|
|
11
|
+
from .file_id_utils import FileId
|
|
12
|
+
from .filename_validator import FileNameValidator
|
|
13
|
+
from .path_utils import abs_path, resolve_path, validate_file_access, validate_folder_access
|
|
14
|
+
|
|
15
|
+
__version__ = version("vcti-path")
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"FileNameValidator",
|
|
20
|
+
"FileId",
|
|
21
|
+
"abs_path",
|
|
22
|
+
"validate_file_access",
|
|
23
|
+
"validate_folder_access",
|
|
24
|
+
"resolve_path",
|
|
25
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Utilities for validating and resolving file identifiers relative to a base directory."""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .filename_validator import FileNameValidator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FileId:
|
|
11
|
+
"""
|
|
12
|
+
Utilities to work with file identifiers--POSIX-style relative paths
|
|
13
|
+
representing files or directories inside a base directory.
|
|
14
|
+
|
|
15
|
+
Rules for a valid file ID:
|
|
16
|
+
- Must not start with "/", "./", "../"
|
|
17
|
+
- Must use "/" as the path separator (POSIX format)
|
|
18
|
+
- Each part must be a valid file/folder name (see FileNameValidator)
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
file_id = "scripts/setup.sh" -> base_dir / "scripts/setup.sh"
|
|
22
|
+
|
|
23
|
+
This utility supports:
|
|
24
|
+
- Validating file IDs
|
|
25
|
+
- Resolving file IDs to full paths
|
|
26
|
+
- Extracting file IDs from full paths
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def is_valid(file_id: str) -> bool:
|
|
31
|
+
"""
|
|
32
|
+
Check if a file ID is valid.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
file_id (str): A relative POSIX-style file ID.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
bool: True if valid, False otherwise.
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(file_id, str):
|
|
41
|
+
raise TypeError(f"file_id must be a str, got {type(file_id).__name__}")
|
|
42
|
+
|
|
43
|
+
if not file_id or file_id.strip() == "":
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
if file_id.startswith(("/", "./", "../")):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
if file_id.endswith("/") and len(file_id) > 1:
|
|
50
|
+
file_id = file_id.rstrip("/")
|
|
51
|
+
|
|
52
|
+
parts = file_id.split("/")
|
|
53
|
+
return all(FileNameValidator.is_valid(part) for part in parts)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def validate(file_id: str) -> None:
|
|
57
|
+
"""
|
|
58
|
+
Validate a file ID and raise ValueError if invalid.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
file_id (str): File ID to validate.
|
|
62
|
+
|
|
63
|
+
Raises:
|
|
64
|
+
TypeError: If file_id is not a str.
|
|
65
|
+
ValueError: If the file ID is invalid.
|
|
66
|
+
"""
|
|
67
|
+
if not FileId.is_valid(file_id):
|
|
68
|
+
raise ValueError(f"Invalid file ID: '{file_id}'")
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def resolve_path(
|
|
72
|
+
file_id: str | None,
|
|
73
|
+
base_dir: Path,
|
|
74
|
+
*,
|
|
75
|
+
must_exist: bool = False,
|
|
76
|
+
must_be_dir: bool = False,
|
|
77
|
+
must_be_file: bool = False,
|
|
78
|
+
) -> Path:
|
|
79
|
+
"""
|
|
80
|
+
Resolve a file ID to an absolute path inside the base directory.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
file_id: Relative file ID in POSIX format. If None or empty string,
|
|
84
|
+
returns base_dir.
|
|
85
|
+
base_dir: The root directory to resolve from.
|
|
86
|
+
must_exist: Raise FileNotFoundError if path doesn't exist.
|
|
87
|
+
must_be_dir: Raise NotADirectoryError if path isn't a directory.
|
|
88
|
+
must_be_file: Raise IsADirectoryError if path isn't a file.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path: Absolute resolved path.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: If file ID is invalid.
|
|
95
|
+
RuntimeError: If resolved path is a symbolic link.
|
|
96
|
+
FileNotFoundError, NotADirectoryError, IsADirectoryError: Based on flags.
|
|
97
|
+
|
|
98
|
+
Note:
|
|
99
|
+
Symbolic links are rejected to prevent escaping the base directory.
|
|
100
|
+
For general-purpose path validation that follows symlinks, use
|
|
101
|
+
``validate_file_access`` or ``validate_folder_access`` instead.
|
|
102
|
+
"""
|
|
103
|
+
# Validate file ID before joining to base_dir
|
|
104
|
+
if file_id:
|
|
105
|
+
FileId.validate(file_id)
|
|
106
|
+
|
|
107
|
+
target = base_dir.joinpath(Path(file_id)) if file_id else base_dir
|
|
108
|
+
|
|
109
|
+
if must_exist and not target.exists():
|
|
110
|
+
raise FileNotFoundError(f"Path does not exist: {target}")
|
|
111
|
+
|
|
112
|
+
if target.exists():
|
|
113
|
+
if target.is_symlink():
|
|
114
|
+
raise RuntimeError(f"Symbolic links are not supported: {target}")
|
|
115
|
+
if must_be_dir and not target.is_dir():
|
|
116
|
+
raise NotADirectoryError(f"Expected a directory: {target}")
|
|
117
|
+
if must_be_file and not target.is_file():
|
|
118
|
+
raise IsADirectoryError(f"Expected a file: {target}")
|
|
119
|
+
|
|
120
|
+
return target
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def get_file_id(file_path: Path, base_dir: Path) -> str:
|
|
124
|
+
"""
|
|
125
|
+
Get the POSIX-style file ID for a file path inside the base directory.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
file_path: The absolute or relative path to convert.
|
|
129
|
+
base_dir: The root directory to compute relative to.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
str: File ID in POSIX format.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
TypeError: If file_path or base_dir is not a Path.
|
|
136
|
+
ValueError: If file_path is outside the base_dir.
|
|
137
|
+
"""
|
|
138
|
+
if not isinstance(file_path, Path):
|
|
139
|
+
raise TypeError(f"file_path must be a Path, got {type(file_path).__name__}")
|
|
140
|
+
if not isinstance(base_dir, Path):
|
|
141
|
+
raise TypeError(f"base_dir must be a Path, got {type(base_dir).__name__}")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
rel_path = file_path.relative_to(base_dir)
|
|
145
|
+
except ValueError:
|
|
146
|
+
raise ValueError(f"Path '{file_path}' is not under base directory '{base_dir}'")
|
|
147
|
+
|
|
148
|
+
if rel_path == Path("."):
|
|
149
|
+
return ""
|
|
150
|
+
|
|
151
|
+
parts = rel_path.parts
|
|
152
|
+
for part in parts:
|
|
153
|
+
FileNameValidator.validate(part)
|
|
154
|
+
|
|
155
|
+
file_id = rel_path.as_posix()
|
|
156
|
+
|
|
157
|
+
if file_path.exists() and file_path.is_dir() and rel_path != Path("."):
|
|
158
|
+
return file_id + "/"
|
|
159
|
+
|
|
160
|
+
return file_id
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Utility class for validating file and folder names."""
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileNameValidator:
|
|
9
|
+
"""
|
|
10
|
+
Validates basic file or folder names based on common restrictions.
|
|
11
|
+
|
|
12
|
+
Rules:
|
|
13
|
+
- Must not be "." or ".."
|
|
14
|
+
- Must not be empty or whitespace only
|
|
15
|
+
- Must not contain illegal characters: / \\ : * ? " < > |
|
|
16
|
+
|
|
17
|
+
This class is useful for validating user-supplied names for files or
|
|
18
|
+
directories before creating them on the filesystem.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
>>> FileNameValidator.is_valid("file.txt") # Returns True
|
|
22
|
+
>>> FileNameValidator.is_valid("invalid|file") # Returns False
|
|
23
|
+
>>> FileNameValidator.validate("data.json") # OK
|
|
24
|
+
>>> FileNameValidator.validate("bad/name") # Raises ValueError
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
INVALID_CHARS = r'<>:"/\\|?*'
|
|
28
|
+
INVALID_PATTERN = re.compile(rf"[{re.escape(INVALID_CHARS)}]")
|
|
29
|
+
RESERVED_NAMES = {".", ".."}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def is_valid(cls, name: str) -> bool:
|
|
33
|
+
"""
|
|
34
|
+
Returns whether the given file or folder name is valid.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
name (str): Name to check.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
bool: True if valid, False otherwise.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(name, str):
|
|
43
|
+
raise TypeError(f"name must be a str, got {type(name).__name__}")
|
|
44
|
+
|
|
45
|
+
if not name or name.strip() == "":
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
if name in cls.RESERVED_NAMES:
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
if cls.INVALID_PATTERN.search(name):
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def validate(cls, name: str) -> None:
|
|
58
|
+
"""
|
|
59
|
+
Validates the given file or folder name.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
name (str): The filename to validate.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
TypeError: If name is not a str.
|
|
66
|
+
ValueError: If the name is reserved, empty, or contains illegal characters.
|
|
67
|
+
"""
|
|
68
|
+
if not cls.is_valid(name):
|
|
69
|
+
raise ValueError(f"Invalid file or directory name: {name!r}")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Copyright Visual Collaboration Technologies Inc. All Rights Reserved.
|
|
2
|
+
# See LICENSE for details.
|
|
3
|
+
"""Utility functions for handling file and folder paths."""
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def abs_path(fp: Path | str) -> Path:
|
|
11
|
+
"""
|
|
12
|
+
Converts a given file path to an absolute, resolved `Path` object.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
fp: The input file path.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Path: The absolute resolved file path.
|
|
19
|
+
"""
|
|
20
|
+
return Path(fp).resolve()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_file_access(fp: Path | str) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Validates that the specified file path exists and is a readable file.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
fp: The file path to validate.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
FileNotFoundError: If the file does not exist.
|
|
32
|
+
IsADirectoryError: If the path points to a directory instead of a file.
|
|
33
|
+
PermissionError: If the file exists but is not readable.
|
|
34
|
+
"""
|
|
35
|
+
path = Path(fp)
|
|
36
|
+
|
|
37
|
+
if not path.exists():
|
|
38
|
+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path))
|
|
39
|
+
|
|
40
|
+
if path.is_dir():
|
|
41
|
+
raise IsADirectoryError(errno.EISDIR, os.strerror(errno.EISDIR), str(path))
|
|
42
|
+
|
|
43
|
+
if not path.is_file():
|
|
44
|
+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path))
|
|
45
|
+
|
|
46
|
+
if not os.access(path, os.R_OK):
|
|
47
|
+
raise PermissionError(errno.EACCES, os.strerror(errno.EACCES), str(path))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_folder_access(fp: Path | str) -> None:
|
|
51
|
+
"""
|
|
52
|
+
Validates that the specified folder path exists and is a directory.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
fp: The folder path to validate.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
FileNotFoundError: If the directory does not exist.
|
|
59
|
+
NotADirectoryError: If the path is not a directory.
|
|
60
|
+
"""
|
|
61
|
+
path = Path(fp)
|
|
62
|
+
|
|
63
|
+
if not path.exists():
|
|
64
|
+
raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(path))
|
|
65
|
+
|
|
66
|
+
if not path.is_dir():
|
|
67
|
+
raise NotADirectoryError(errno.ENOTDIR, os.strerror(errno.ENOTDIR), str(path))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_path(path: Path | str, base_dir: Path | str) -> Path:
|
|
71
|
+
"""
|
|
72
|
+
Resolves a path relative to a base directory, handling both absolute and relative paths.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
path: The path to resolve (absolute or relative)
|
|
76
|
+
base_dir: The base directory for relative path resolution
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Path: The fully resolved absolute path
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
FileNotFoundError: If the base_dir does not exist
|
|
83
|
+
NotADirectoryError: If base_dir is not a directory
|
|
84
|
+
|
|
85
|
+
Note:
|
|
86
|
+
This function does not prevent directory traversal via ``..``.
|
|
87
|
+
For untrusted input that must stay within a base directory, use
|
|
88
|
+
``FileId.resolve_path`` which validates the path format first.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(path, (str, Path)):
|
|
91
|
+
raise TypeError(f"path must be a str or Path, got {type(path).__name__}")
|
|
92
|
+
if not isinstance(base_dir, (str, Path)):
|
|
93
|
+
raise TypeError(f"base_dir must be a str or Path, got {type(base_dir).__name__}")
|
|
94
|
+
|
|
95
|
+
path = Path(path) if isinstance(path, str) else path
|
|
96
|
+
base_dir = Path(base_dir) if isinstance(base_dir, str) else base_dir
|
|
97
|
+
|
|
98
|
+
# Resolve the path
|
|
99
|
+
if not path.is_absolute():
|
|
100
|
+
validate_folder_access(base_dir)
|
|
101
|
+
path = base_dir / path
|
|
102
|
+
|
|
103
|
+
return path.resolve()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
__all__ = [
|
|
107
|
+
"abs_path",
|
|
108
|
+
"validate_file_access",
|
|
109
|
+
"validate_folder_access",
|
|
110
|
+
"resolve_path",
|
|
111
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vcti-path
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Safe path handling for VCollab applications
|
|
5
|
+
Author: Visual Collaboration Technologies Inc.
|
|
6
|
+
Requires-Python: <3.15,>=3.12
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest; extra == "test"
|
|
11
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
12
|
+
Provides-Extra: lint
|
|
13
|
+
Requires-Dist: ruff; extra == "lint"
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# Path Utilities
|
|
17
|
+
|
|
18
|
+
## Purpose
|
|
19
|
+
|
|
20
|
+
Safe path handling for VCollab applications -- filename validation, file
|
|
21
|
+
identity (POSIX-style relative paths as stable IDs), and path resolution
|
|
22
|
+
with access validation.
|
|
23
|
+
|
|
24
|
+
This package has **zero external dependencies**.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install vcti-path
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### In `requirements.txt`
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
vcti-path>=1.0.3
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### In `pyproject.toml` dependencies
|
|
41
|
+
|
|
42
|
+
```toml
|
|
43
|
+
dependencies = [
|
|
44
|
+
"vcti-path>=1.0.3",
|
|
45
|
+
]
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
### Validate filenames
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from vcti.path import FileNameValidator
|
|
56
|
+
|
|
57
|
+
FileNameValidator.is_valid("report.pdf") # True
|
|
58
|
+
FileNameValidator.is_valid("bad|name") # False
|
|
59
|
+
FileNameValidator.is_valid("..") # False
|
|
60
|
+
|
|
61
|
+
FileNameValidator.validate("data.json") # OK
|
|
62
|
+
FileNameValidator.validate("bad/name") # Raises ValueError
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### File IDs -- stable POSIX-style identifiers
|
|
66
|
+
|
|
67
|
+
A file ID is a POSIX-format relative path that uniquely identifies a
|
|
68
|
+
file or directory within a base directory. No leading `/`, `./`, or `../`.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
from vcti.path import FileId
|
|
73
|
+
|
|
74
|
+
# Validate
|
|
75
|
+
FileId.is_valid("scripts/setup.sh") # True
|
|
76
|
+
FileId.is_valid("/absolute/path") # False
|
|
77
|
+
FileId.is_valid("../escape") # False
|
|
78
|
+
|
|
79
|
+
# Resolve to filesystem path
|
|
80
|
+
base = Path("/project")
|
|
81
|
+
FileId.resolve_path("src/main.py", base)
|
|
82
|
+
# -> Path("/project/src/main.py")
|
|
83
|
+
|
|
84
|
+
# Extract file ID from a path
|
|
85
|
+
src = Path("/project/src/main.py")
|
|
86
|
+
FileId.get_file_id(src, base)
|
|
87
|
+
# -> "src/main.py"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Path resolution and access validation
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from vcti.path import abs_path, validate_file_access, resolve_path
|
|
94
|
+
|
|
95
|
+
# Absolute path resolution
|
|
96
|
+
abs_path("relative/file.txt")
|
|
97
|
+
# -> Path("<cwd>/relative/file.txt")
|
|
98
|
+
|
|
99
|
+
# Validate that a file exists and is readable
|
|
100
|
+
validate_file_access("config.yaml")
|
|
101
|
+
# Raises FileNotFoundError, IsADirectoryError, or PermissionError
|
|
102
|
+
|
|
103
|
+
# Resolve relative to a base directory
|
|
104
|
+
resolve_path("data/input.csv", "/project")
|
|
105
|
+
# -> Path("/project/data/input.csv")
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Public API
|
|
111
|
+
|
|
112
|
+
| Symbol | Type | Purpose |
|
|
113
|
+
|--------|------|---------|
|
|
114
|
+
| `FileNameValidator.is_valid(name)` | classmethod | Check if a filename is valid |
|
|
115
|
+
| `FileNameValidator.validate(name)` | classmethod | Validate filename, raise `ValueError` if invalid |
|
|
116
|
+
| `FileId.is_valid(file_id)` | staticmethod | Check if a POSIX file ID is valid |
|
|
117
|
+
| `FileId.validate(file_id)` | staticmethod | Validate file ID, raise `ValueError` if invalid |
|
|
118
|
+
| `FileId.resolve_path(file_id, base_dir)` | staticmethod | Resolve file ID to absolute path |
|
|
119
|
+
| `FileId.get_file_id(file_path, base_dir)` | staticmethod | Extract file ID from absolute path |
|
|
120
|
+
| `abs_path(fp)` | function | Convert to absolute resolved `Path` |
|
|
121
|
+
| `validate_file_access(fp)` | function | Validate file exists and is readable |
|
|
122
|
+
| `validate_folder_access(fp)` | function | Validate directory exists |
|
|
123
|
+
| `resolve_path(path, base_dir)` | function | Resolve path relative to base directory |
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
|
|
129
|
+
- [Design](docs/design.md) -- Concepts, architecture decisions, and rationale
|
|
130
|
+
- [Source Guide](docs/source-guide.md) -- File descriptions and execution flow traces
|
|
131
|
+
- [API Reference](docs/api.md) -- Autodoc for all modules
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/vcti/path/__init__.py
|
|
5
|
+
src/vcti/path/file_id_utils.py
|
|
6
|
+
src/vcti/path/filename_validator.py
|
|
7
|
+
src/vcti/path/path_utils.py
|
|
8
|
+
src/vcti/path/py.typed
|
|
9
|
+
src/vcti_path.egg-info/PKG-INFO
|
|
10
|
+
src/vcti_path.egg-info/SOURCES.txt
|
|
11
|
+
src/vcti_path.egg-info/dependency_links.txt
|
|
12
|
+
src/vcti_path.egg-info/requires.txt
|
|
13
|
+
src/vcti_path.egg-info/top_level.txt
|
|
14
|
+
src/vcti_path.egg-info/zip-safe
|
|
15
|
+
tests/test_file_id_utils.py
|
|
16
|
+
tests/test_filename_validator.py
|
|
17
|
+
tests/test_path_utils.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vcti
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from vcti.path.file_id_utils import FileId
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestFileIdIsValid:
|
|
9
|
+
"""Tests for FileId.is_valid."""
|
|
10
|
+
|
|
11
|
+
def test_simple_filename(self):
|
|
12
|
+
assert FileId.is_valid("file.txt") is True
|
|
13
|
+
|
|
14
|
+
def test_nested_path(self):
|
|
15
|
+
assert FileId.is_valid("scripts/setup.sh") is True
|
|
16
|
+
|
|
17
|
+
def test_deeply_nested(self):
|
|
18
|
+
assert FileId.is_valid("a/b/c/d.txt") is True
|
|
19
|
+
|
|
20
|
+
def test_empty_string_invalid(self):
|
|
21
|
+
assert FileId.is_valid("") is False
|
|
22
|
+
|
|
23
|
+
def test_whitespace_only_invalid(self):
|
|
24
|
+
assert FileId.is_valid(" ") is False
|
|
25
|
+
|
|
26
|
+
def test_absolute_path_invalid(self):
|
|
27
|
+
assert FileId.is_valid("/absolute/path") is False
|
|
28
|
+
|
|
29
|
+
def test_dot_slash_prefix_invalid(self):
|
|
30
|
+
assert FileId.is_valid("./relative") is False
|
|
31
|
+
|
|
32
|
+
def test_dotdot_slash_prefix_invalid(self):
|
|
33
|
+
assert FileId.is_valid("../parent") is False
|
|
34
|
+
|
|
35
|
+
def test_trailing_slash_valid(self):
|
|
36
|
+
assert FileId.is_valid("folder/") is True
|
|
37
|
+
|
|
38
|
+
def test_name_with_illegal_chars_invalid(self):
|
|
39
|
+
assert FileId.is_valid("bad|name") is False
|
|
40
|
+
|
|
41
|
+
def test_name_with_colon_invalid(self):
|
|
42
|
+
assert FileId.is_valid("bad:name") is False
|
|
43
|
+
|
|
44
|
+
def test_backslash_path_invalid(self):
|
|
45
|
+
assert FileId.is_valid("folder\\file.txt") is False
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize("invalid_input", [None, 123, b"bytes", ["list"]])
|
|
48
|
+
def test_non_string_raises_type_error(self, invalid_input):
|
|
49
|
+
with pytest.raises(TypeError, match="file_id must be a str"):
|
|
50
|
+
FileId.is_valid(invalid_input)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestFileIdValidate:
|
|
54
|
+
"""Tests for FileId.validate."""
|
|
55
|
+
|
|
56
|
+
def test_valid_raises_nothing(self):
|
|
57
|
+
FileId.validate("scripts/run.sh") # Should not raise
|
|
58
|
+
|
|
59
|
+
def test_invalid_raises_value_error(self):
|
|
60
|
+
with pytest.raises(ValueError, match="Invalid file ID"):
|
|
61
|
+
FileId.validate("/absolute")
|
|
62
|
+
|
|
63
|
+
def test_empty_raises_value_error(self):
|
|
64
|
+
with pytest.raises(ValueError):
|
|
65
|
+
FileId.validate("")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestFileIdResolvePath:
|
|
69
|
+
"""Tests for FileId.resolve_path."""
|
|
70
|
+
|
|
71
|
+
def test_none_returns_base_dir(self, tmp_path):
|
|
72
|
+
result = FileId.resolve_path(None, tmp_path)
|
|
73
|
+
assert result == tmp_path
|
|
74
|
+
|
|
75
|
+
def test_empty_string_returns_base_dir(self, tmp_path):
|
|
76
|
+
result = FileId.resolve_path("", tmp_path)
|
|
77
|
+
assert result == tmp_path
|
|
78
|
+
|
|
79
|
+
def test_resolves_relative_id(self, tmp_path):
|
|
80
|
+
result = FileId.resolve_path("subdir/file.txt", tmp_path)
|
|
81
|
+
assert result == tmp_path / "subdir" / "file.txt"
|
|
82
|
+
|
|
83
|
+
def test_must_exist_raises_if_missing(self, tmp_path):
|
|
84
|
+
with pytest.raises(FileNotFoundError):
|
|
85
|
+
FileId.resolve_path("nonexistent.txt", tmp_path, must_exist=True)
|
|
86
|
+
|
|
87
|
+
def test_must_exist_passes_if_file_exists(self, tmp_path):
|
|
88
|
+
f = tmp_path / "real.txt"
|
|
89
|
+
f.write_text("data")
|
|
90
|
+
result = FileId.resolve_path("real.txt", tmp_path, must_exist=True)
|
|
91
|
+
assert result == f
|
|
92
|
+
|
|
93
|
+
def test_must_be_file_raises_for_dir(self, tmp_path):
|
|
94
|
+
d = tmp_path / "mydir"
|
|
95
|
+
d.mkdir()
|
|
96
|
+
with pytest.raises(IsADirectoryError):
|
|
97
|
+
FileId.resolve_path("mydir", tmp_path, must_be_file=True)
|
|
98
|
+
|
|
99
|
+
def test_must_be_dir_raises_for_file(self, tmp_path):
|
|
100
|
+
f = tmp_path / "file.txt"
|
|
101
|
+
f.write_text("data")
|
|
102
|
+
with pytest.raises(NotADirectoryError):
|
|
103
|
+
FileId.resolve_path("file.txt", tmp_path, must_be_dir=True)
|
|
104
|
+
|
|
105
|
+
def test_invalid_file_id_raises_value_error(self, tmp_path):
|
|
106
|
+
with pytest.raises(ValueError):
|
|
107
|
+
FileId.resolve_path("/absolute", tmp_path)
|
|
108
|
+
|
|
109
|
+
# Note: Windows junctions/reparse points are not tested
|
|
110
|
+
@pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks only")
|
|
111
|
+
def test_symlink_raises_runtime_error(self, tmp_path):
|
|
112
|
+
target = tmp_path / "real.txt"
|
|
113
|
+
target.write_text("data")
|
|
114
|
+
link = tmp_path / "link.txt"
|
|
115
|
+
link.symlink_to(target)
|
|
116
|
+
with pytest.raises(RuntimeError, match="Symbolic links are not supported"):
|
|
117
|
+
FileId.resolve_path("link.txt", tmp_path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestFileIdGetFileId:
|
|
121
|
+
"""Tests for FileId.get_file_id."""
|
|
122
|
+
|
|
123
|
+
def test_file_returns_posix_id(self, tmp_path):
|
|
124
|
+
f = tmp_path / "data.txt"
|
|
125
|
+
f.write_text("x")
|
|
126
|
+
result = FileId.get_file_id(f, tmp_path)
|
|
127
|
+
assert result == "data.txt"
|
|
128
|
+
|
|
129
|
+
def test_nested_file_returns_posix_id(self, tmp_path):
|
|
130
|
+
sub = tmp_path / "sub"
|
|
131
|
+
sub.mkdir()
|
|
132
|
+
f = sub / "file.txt"
|
|
133
|
+
f.write_text("x")
|
|
134
|
+
result = FileId.get_file_id(f, tmp_path)
|
|
135
|
+
assert result == "sub/file.txt"
|
|
136
|
+
|
|
137
|
+
def test_directory_returns_trailing_slash(self, tmp_path):
|
|
138
|
+
d = tmp_path / "mydir"
|
|
139
|
+
d.mkdir()
|
|
140
|
+
result = FileId.get_file_id(d, tmp_path)
|
|
141
|
+
assert result == "mydir/"
|
|
142
|
+
|
|
143
|
+
def test_base_dir_itself_returns_empty(self, tmp_path):
|
|
144
|
+
result = FileId.get_file_id(tmp_path, tmp_path)
|
|
145
|
+
assert result == ""
|
|
146
|
+
|
|
147
|
+
def test_outside_base_dir_raises(self, tmp_path):
|
|
148
|
+
outside = tmp_path.parent / "other.txt"
|
|
149
|
+
with pytest.raises(ValueError, match="not under base directory"):
|
|
150
|
+
FileId.get_file_id(outside, tmp_path)
|
|
151
|
+
|
|
152
|
+
def test_non_path_file_path_raises_type_error(self, tmp_path):
|
|
153
|
+
with pytest.raises(TypeError, match="file_path must be a Path"):
|
|
154
|
+
FileId.get_file_id("string/path", tmp_path)
|
|
155
|
+
|
|
156
|
+
def test_non_path_base_dir_raises_type_error(self, tmp_path):
|
|
157
|
+
with pytest.raises(TypeError, match="base_dir must be a Path"):
|
|
158
|
+
FileId.get_file_id(tmp_path, "/string/base")
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
import vcti.path
|
|
6
|
+
from vcti.path.filename_validator import FileNameValidator
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestVersion:
|
|
10
|
+
"""Tests for package version metadata."""
|
|
11
|
+
|
|
12
|
+
def test_version_exists(self):
|
|
13
|
+
assert hasattr(vcti.path, "__version__")
|
|
14
|
+
|
|
15
|
+
def test_version_is_valid_semver(self):
|
|
16
|
+
assert re.match(r"^\d+\.\d+\.\d+", vcti.path.__version__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestPublicAPI:
|
|
20
|
+
"""Tests that all public symbols are re-exported from vcti.path."""
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize(
|
|
23
|
+
"symbol",
|
|
24
|
+
[
|
|
25
|
+
"FileNameValidator",
|
|
26
|
+
"FileId",
|
|
27
|
+
"abs_path",
|
|
28
|
+
"validate_file_access",
|
|
29
|
+
"validate_folder_access",
|
|
30
|
+
"resolve_path",
|
|
31
|
+
],
|
|
32
|
+
)
|
|
33
|
+
def test_public_symbol_exported(self, symbol):
|
|
34
|
+
assert hasattr(vcti.path, symbol)
|
|
35
|
+
assert symbol in vcti.path.__all__
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestFileNameValidatorIsValid:
|
|
39
|
+
"""Tests for FileNameValidator.is_valid."""
|
|
40
|
+
|
|
41
|
+
def test_valid_simple_name(self):
|
|
42
|
+
assert FileNameValidator.is_valid("file.txt") is True
|
|
43
|
+
|
|
44
|
+
def test_valid_name_no_extension(self):
|
|
45
|
+
assert FileNameValidator.is_valid("myfile") is True
|
|
46
|
+
|
|
47
|
+
def test_valid_name_with_spaces(self):
|
|
48
|
+
assert FileNameValidator.is_valid("my file.txt") is True
|
|
49
|
+
|
|
50
|
+
def test_valid_name_with_underscores_dashes(self):
|
|
51
|
+
assert FileNameValidator.is_valid("my-file_name.txt") is True
|
|
52
|
+
|
|
53
|
+
def test_empty_string_invalid(self):
|
|
54
|
+
assert FileNameValidator.is_valid("") is False
|
|
55
|
+
|
|
56
|
+
def test_whitespace_only_invalid(self):
|
|
57
|
+
assert FileNameValidator.is_valid(" ") is False
|
|
58
|
+
|
|
59
|
+
def test_dot_reserved_invalid(self):
|
|
60
|
+
assert FileNameValidator.is_valid(".") is False
|
|
61
|
+
|
|
62
|
+
def test_dotdot_reserved_invalid(self):
|
|
63
|
+
assert FileNameValidator.is_valid("..") is False
|
|
64
|
+
|
|
65
|
+
@pytest.mark.parametrize("char", ["/", "\\", ":", "*", "?", '"', "<", ">", "|"])
|
|
66
|
+
def test_illegal_characters_invalid(self, char):
|
|
67
|
+
assert FileNameValidator.is_valid(f"file{char}name") is False
|
|
68
|
+
|
|
69
|
+
def test_valid_hidden_file_unix(self):
|
|
70
|
+
assert FileNameValidator.is_valid(".hidden") is True
|
|
71
|
+
|
|
72
|
+
def test_valid_numeric_name(self):
|
|
73
|
+
assert FileNameValidator.is_valid("12345") is True
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("invalid_input", [None, 123, b"bytes", ["list"]])
|
|
76
|
+
def test_non_string_raises_type_error(self, invalid_input):
|
|
77
|
+
with pytest.raises(TypeError, match="name must be a str"):
|
|
78
|
+
FileNameValidator.is_valid(invalid_input)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TestFileNameValidatorValidate:
|
|
82
|
+
"""Tests for FileNameValidator.validate."""
|
|
83
|
+
|
|
84
|
+
def test_valid_name_no_exception(self):
|
|
85
|
+
FileNameValidator.validate("data.json") # Should not raise
|
|
86
|
+
|
|
87
|
+
def test_empty_raises_value_error(self):
|
|
88
|
+
with pytest.raises(ValueError, match="Invalid file or directory name"):
|
|
89
|
+
FileNameValidator.validate("")
|
|
90
|
+
|
|
91
|
+
def test_dot_raises_value_error(self):
|
|
92
|
+
with pytest.raises(ValueError):
|
|
93
|
+
FileNameValidator.validate(".")
|
|
94
|
+
|
|
95
|
+
def test_illegal_char_raises_value_error(self):
|
|
96
|
+
with pytest.raises(ValueError):
|
|
97
|
+
FileNameValidator.validate("bad/name")
|
|
98
|
+
|
|
99
|
+
def test_dotdot_raises_value_error(self):
|
|
100
|
+
with pytest.raises(ValueError):
|
|
101
|
+
FileNameValidator.validate("..")
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from vcti.path.path_utils import (
|
|
8
|
+
abs_path,
|
|
9
|
+
resolve_path,
|
|
10
|
+
validate_file_access,
|
|
11
|
+
validate_folder_access,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestPathUtils:
|
|
16
|
+
"""Test cases for path utility functions."""
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def setup_teardown(self, tmp_path):
|
|
20
|
+
"""Create temporary files and directories for testing."""
|
|
21
|
+
self.test_dir = tmp_path / "test_directory"
|
|
22
|
+
self.test_dir.mkdir()
|
|
23
|
+
|
|
24
|
+
self.test_file = tmp_path / "test_file.txt"
|
|
25
|
+
self.test_file.write_text("Sample content")
|
|
26
|
+
|
|
27
|
+
self.non_existent_path = tmp_path / "non_existent.txt"
|
|
28
|
+
self.subdir = self.test_dir / "subdir"
|
|
29
|
+
self.subdir.mkdir()
|
|
30
|
+
|
|
31
|
+
# Store original permissions to restore later
|
|
32
|
+
self.original_mode = self.test_file.stat().st_mode
|
|
33
|
+
yield # This is where the testing happens
|
|
34
|
+
|
|
35
|
+
# Restore original permissions (in case they were modified)
|
|
36
|
+
self.test_file.chmod(self.original_mode)
|
|
37
|
+
|
|
38
|
+
def test_abs_path_with_str(self):
|
|
39
|
+
"""Test absolute path resolution with string input."""
|
|
40
|
+
relative_path = str(self.test_file)
|
|
41
|
+
result = abs_path(relative_path)
|
|
42
|
+
assert result == Path(relative_path).resolve()
|
|
43
|
+
assert isinstance(result, Path)
|
|
44
|
+
|
|
45
|
+
def test_abs_path_with_path(self):
|
|
46
|
+
"""Test absolute path resolution with Path object input."""
|
|
47
|
+
result = abs_path(self.test_file)
|
|
48
|
+
assert result == self.test_file.resolve()
|
|
49
|
+
assert isinstance(result, Path)
|
|
50
|
+
|
|
51
|
+
def test_validate_file_access_valid(self):
|
|
52
|
+
"""Test validate_file_access with valid file."""
|
|
53
|
+
validate_file_access(self.test_file) # Should not raise
|
|
54
|
+
|
|
55
|
+
def test_validate_file_access_non_existent(self):
|
|
56
|
+
"""Test validate_file_access with non-existent file."""
|
|
57
|
+
with pytest.raises(FileNotFoundError):
|
|
58
|
+
validate_file_access(self.non_existent_path)
|
|
59
|
+
|
|
60
|
+
def test_validate_file_access_is_directory(self):
|
|
61
|
+
"""Test validate_file_access with directory."""
|
|
62
|
+
with pytest.raises(IsADirectoryError):
|
|
63
|
+
validate_file_access(self.test_dir)
|
|
64
|
+
|
|
65
|
+
# Note: Windows ACL-based permission testing is beyond scope
|
|
66
|
+
@pytest.mark.skipif(os.name != "posix", reason="POSIX permissions only")
|
|
67
|
+
def test_validate_file_access_no_permission(self):
|
|
68
|
+
"""Test validate_file_access with unreadable file (POSIX only)."""
|
|
69
|
+
self.test_file.chmod(0o000) # Remove all permissions
|
|
70
|
+
with pytest.raises(PermissionError):
|
|
71
|
+
validate_file_access(self.test_file)
|
|
72
|
+
|
|
73
|
+
def test_validate_folder_access_valid(self):
|
|
74
|
+
"""Test validate_folder_access with valid directory."""
|
|
75
|
+
validate_folder_access(self.test_dir) # Should not raise
|
|
76
|
+
|
|
77
|
+
def test_validate_folder_access_non_existent(self):
|
|
78
|
+
"""Test validate_folder_access with non-existent directory."""
|
|
79
|
+
with pytest.raises(FileNotFoundError):
|
|
80
|
+
validate_folder_access(self.non_existent_path)
|
|
81
|
+
|
|
82
|
+
def test_validate_folder_access_not_directory(self):
|
|
83
|
+
"""Test validate_folder_access with file."""
|
|
84
|
+
with pytest.raises(NotADirectoryError):
|
|
85
|
+
validate_folder_access(self.test_file)
|
|
86
|
+
|
|
87
|
+
@pytest.mark.parametrize(
|
|
88
|
+
"path_input",
|
|
89
|
+
[
|
|
90
|
+
b"bytes_path", # bytes input
|
|
91
|
+
123, # integer input
|
|
92
|
+
],
|
|
93
|
+
)
|
|
94
|
+
def test_abs_path_with_wrong_types(self, path_input):
|
|
95
|
+
"""Test abs_path with unusual input types."""
|
|
96
|
+
with pytest.raises(TypeError):
|
|
97
|
+
abs_path(path_input)
|
|
98
|
+
|
|
99
|
+
@patch("pathlib.Path.exists", return_value=True)
|
|
100
|
+
@patch("pathlib.Path.is_file", return_value=True)
|
|
101
|
+
@patch("vcti.path.path_utils.os.access")
|
|
102
|
+
def test_validate_file_access_mocked(self, mock_access, mock_is_file, mock_exists):
|
|
103
|
+
"""Test validate_file_access with mocked permissions."""
|
|
104
|
+
mock_access.return_value = True # Readable
|
|
105
|
+
validate_file_access("any_path") # Should pass with mocked permissions
|
|
106
|
+
|
|
107
|
+
mock_access.return_value = False # Not readable
|
|
108
|
+
with pytest.raises(PermissionError):
|
|
109
|
+
validate_file_access("any_path")
|
|
110
|
+
|
|
111
|
+
# Note: Windows junctions/reparse points are not tested
|
|
112
|
+
@pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks only")
|
|
113
|
+
def test_symlink_valid_target(self):
|
|
114
|
+
"""Test validate_file_access follows symlink to valid target."""
|
|
115
|
+
symlink_path = self.test_dir / "symlink.txt"
|
|
116
|
+
symlink_path.symlink_to(self.test_file)
|
|
117
|
+
validate_file_access(symlink_path)
|
|
118
|
+
|
|
119
|
+
@pytest.mark.skipif(os.name != "posix", reason="POSIX symlinks only")
|
|
120
|
+
def test_symlink_broken_raises_file_not_found(self):
|
|
121
|
+
"""Test validate_file_access raises for broken symlink."""
|
|
122
|
+
broken_symlink = self.test_dir / "broken_symlink"
|
|
123
|
+
broken_symlink.symlink_to("nonexistent_target")
|
|
124
|
+
with pytest.raises(FileNotFoundError):
|
|
125
|
+
validate_file_access(broken_symlink)
|
|
126
|
+
|
|
127
|
+
def test_resolve_path_absolute(self, tmp_path):
|
|
128
|
+
"""Test resolve_path with absolute path input."""
|
|
129
|
+
abs_test_file = self.test_file.resolve()
|
|
130
|
+
result = resolve_path(abs_test_file, tmp_path)
|
|
131
|
+
assert result == abs_test_file
|
|
132
|
+
|
|
133
|
+
def test_resolve_path_relative(self, tmp_path):
|
|
134
|
+
"""Test resolve_path with relative path input."""
|
|
135
|
+
rel_path = "test_directory/subdir"
|
|
136
|
+
result = resolve_path(rel_path, tmp_path)
|
|
137
|
+
expected = (tmp_path / rel_path).resolve()
|
|
138
|
+
assert result == expected
|
|
139
|
+
|
|
140
|
+
def test_resolve_path_with_str_input(self, tmp_path):
|
|
141
|
+
"""Test resolve_path with string inputs."""
|
|
142
|
+
result = resolve_path("test_file.txt", str(tmp_path))
|
|
143
|
+
expected = (tmp_path / "test_file.txt").resolve()
|
|
144
|
+
assert result == expected
|
|
145
|
+
|
|
146
|
+
def test_resolve_path_invalid_base(self):
|
|
147
|
+
"""Test resolve_path with non-existent base path."""
|
|
148
|
+
with pytest.raises(FileNotFoundError):
|
|
149
|
+
resolve_path("any_path", "/non/existent/base")
|
|
150
|
+
|
|
151
|
+
def test_resolve_path_base_not_dir(self, tmp_path):
|
|
152
|
+
"""Test resolve_path when base path is not a directory."""
|
|
153
|
+
with pytest.raises(NotADirectoryError):
|
|
154
|
+
resolve_path("any_path", self.test_file)
|
|
155
|
+
|
|
156
|
+
def test_resolve_path_dot_dot(self, tmp_path):
|
|
157
|
+
"""Test resolve_path with parent directory references."""
|
|
158
|
+
result = resolve_path("../test_file.txt", self.subdir)
|
|
159
|
+
expected = (self.test_dir / "test_file.txt").resolve()
|
|
160
|
+
assert result == expected
|
|
161
|
+
|
|
162
|
+
def test_resolve_path_empty_path(self, tmp_path):
|
|
163
|
+
"""Test resolve_path with empty path string."""
|
|
164
|
+
result = resolve_path("", tmp_path)
|
|
165
|
+
assert result == tmp_path.resolve()
|
|
166
|
+
|
|
167
|
+
def test_resolve_path_dot(self, tmp_path):
|
|
168
|
+
"""Test resolve_path with '.' path."""
|
|
169
|
+
result = resolve_path(".", tmp_path)
|
|
170
|
+
assert result == tmp_path.resolve()
|
|
171
|
+
|
|
172
|
+
def test_resolve_path_relative_to_cwd(self, tmp_path, monkeypatch):
|
|
173
|
+
"""Test that relative paths without base_path use cwd."""
|
|
174
|
+
monkeypatch.chdir(tmp_path)
|
|
175
|
+
rel_path = "test_file.txt"
|
|
176
|
+
result = resolve_path(rel_path, ".")
|
|
177
|
+
expected = (tmp_path / rel_path).resolve()
|
|
178
|
+
assert result == expected
|
|
179
|
+
|
|
180
|
+
@pytest.mark.parametrize(
|
|
181
|
+
"invalid_input",
|
|
182
|
+
[
|
|
183
|
+
None,
|
|
184
|
+
123,
|
|
185
|
+
b"bytes_path",
|
|
186
|
+
],
|
|
187
|
+
)
|
|
188
|
+
def test_resolve_path_invalid_types(self, invalid_input, tmp_path):
|
|
189
|
+
"""Test resolve_path with invalid input types."""
|
|
190
|
+
with pytest.raises(TypeError):
|
|
191
|
+
resolve_path(invalid_input, tmp_path)
|
|
192
|
+
with pytest.raises(TypeError):
|
|
193
|
+
resolve_path("valid_path", invalid_input)
|