rootle 1.1.0__tar.gz → 1.2.0__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.
- {rootle-1.1.0 → rootle-1.2.0}/PKG-INFO +1 -1
- rootle-1.2.0/docs/testing/can_chmod_deny_listing.md +3 -0
- rootle-1.2.0/docs/testing/can_chmod_deny_writing.md +3 -0
- rootle-1.2.0/docs/testing/is_treated_as_same_name.md +3 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/exceptions/_os_error.py +12 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/identity.py +1 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/relative_within.py +1 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/resolve.py +10 -3
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/status.py +7 -4
- rootle-1.2.0/src/rootle/testing/__init__.py +11 -0
- rootle-1.2.0/src/rootle/testing/can_chmod_deny_listing.py +62 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/testing/can_chmod_deny_search.py +3 -1
- rootle-1.2.0/src/rootle/testing/can_chmod_deny_writing.py +71 -0
- rootle-1.2.0/src/rootle/testing/is_treated_as_same_name.py +82 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/marks.py +5 -0
- rootle-1.2.0/tests/test_can_chmod_deny_listing.py +112 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_can_chmod_deny_search.py +35 -6
- rootle-1.2.0/tests/test_can_chmod_deny_writing.py +177 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_identity.py +52 -5
- rootle-1.2.0/tests/test_is_treated_as_same_name.py +320 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_relative_within.py +123 -8
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_resolve.py +52 -5
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_status.py +44 -1
- {rootle-1.1.0 → rootle-1.2.0}/zensical.toml +6 -0
- rootle-1.1.0/src/rootle/testing/__init__.py +0 -5
- {rootle-1.1.0 → rootle-1.2.0}/.config/mise.lock +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.config/mise.toml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.config/rumdl.toml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.config/tombi.toml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.editorconfig +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.gitattributes +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.github/dependabot.yml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.github/workflows/ci.yml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.github/workflows/release.yml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.gitignore +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.python-version +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/.vscode/settings.json +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/CLAUDE.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/CONTRIBUTING.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/LICENSE +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/README.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/functions/ends_like_directory.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/functions/identity.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/functions/relative_within.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/functions/resolve.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/functions/status.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/index.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/testing/can_chmod_deny_search.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/types/identity.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/docs/winerror.md +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/pyproject.toml +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/__init__.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/_identity_of_status.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/_status_of_resolved.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/ends_like_directory.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/exceptions/__init__.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/exceptions/_not_a_directory_error.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/exceptions/_permission_error.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/py.typed +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/types/__init__.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/types/identity.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/src/rootle/winerror.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/__init__.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_docs.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/tests/test_ends_like_directory.py +0 -0
- {rootle-1.1.0 → rootle-1.2.0}/uv.lock +0 -0
|
@@ -1,6 +1,18 @@
|
|
|
1
|
+
from errno import EINVAL
|
|
1
2
|
from os import PathLike, fspath
|
|
2
3
|
|
|
3
4
|
|
|
5
|
+
def null_character(path: PathLike[str] | str) -> OSError:
|
|
6
|
+
"""Make an error that says `path` holds a null character."""
|
|
7
|
+
return OSError(
|
|
8
|
+
# `EINVAL` rather than the `ENOENT` of an empty path, so it can't be mistaken
|
|
9
|
+
# for a missing file.
|
|
10
|
+
EINVAL,
|
|
11
|
+
"The path holds a null character",
|
|
12
|
+
fspath(path),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
4
16
|
def with_path(ex: OSError, path: PathLike[str] | str) -> OSError:
|
|
5
17
|
"""
|
|
6
18
|
Copy `ex` so that it describes `path` as the caller gave it, rather than whichever
|
|
@@ -48,6 +48,7 @@ def identity(path: PathLike[str] | str) -> Identity | None:
|
|
|
48
48
|
- When `path` ends with a separator or `.`, which says it's a directory, but
|
|
49
49
|
leads to a file.
|
|
50
50
|
- When `path` is empty, which names nothing.
|
|
51
|
+
- When `path` contains a null character.
|
|
51
52
|
- When the file exists but its identity can't be read, like when Windows
|
|
52
53
|
can't open it.
|
|
53
54
|
|
|
@@ -100,6 +100,7 @@ def relative_within(
|
|
|
100
100
|
- When `path` or `directory` ends with a separator or `.`, which says it's
|
|
101
101
|
a directory, but leads to a file.
|
|
102
102
|
- When `path` or `directory` is empty, which names nothing.
|
|
103
|
+
- When `path` or `directory` contains a null character.
|
|
103
104
|
- When `directory` is a file.
|
|
104
105
|
- When `directory` exists but its identity can't be read, like when Windows
|
|
105
106
|
can't open it.
|
|
@@ -51,6 +51,7 @@ def resolve(path: PathLike[str] | str) -> Path:
|
|
|
51
51
|
- When `path` ends with a separator or `.`, which says it's a directory, but
|
|
52
52
|
leads to a file.
|
|
53
53
|
- When `path` is empty, which names nothing.
|
|
54
|
+
- When `path` contains a null character.
|
|
54
55
|
|
|
55
56
|
The error names `path` as it was given.
|
|
56
57
|
|
|
@@ -78,16 +79,22 @@ def resolve(path: PathLike[str] | str) -> Path:
|
|
|
78
79
|
# Path("/docs/v2/guide.md/intro.md")
|
|
79
80
|
```
|
|
80
81
|
"""
|
|
82
|
+
name = fspath(path)
|
|
83
|
+
|
|
81
84
|
# `Path` reads an empty path as `.`, the working directory, but it names nothing,
|
|
82
85
|
# like `os.stat` says.
|
|
83
|
-
if not
|
|
86
|
+
if not name:
|
|
84
87
|
raise FileNotFoundError(
|
|
85
88
|
ENOENT,
|
|
86
89
|
strerror(ENOENT),
|
|
87
|
-
|
|
90
|
+
name,
|
|
88
91
|
)
|
|
89
92
|
|
|
90
|
-
|
|
93
|
+
# No path can contain a null character.
|
|
94
|
+
if "\0" in name:
|
|
95
|
+
raise _os_error.null_character(name)
|
|
96
|
+
|
|
97
|
+
current = Path(name)
|
|
91
98
|
tail: list[str] = []
|
|
92
99
|
|
|
93
100
|
while True:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from contextlib import suppress
|
|
2
|
-
from os import PathLike, stat, stat_result
|
|
2
|
+
from os import PathLike, fspath, stat, stat_result
|
|
3
3
|
from os.path import isabs
|
|
4
4
|
|
|
5
5
|
from ._status_of_resolved import status_of_resolved
|
|
@@ -39,6 +39,7 @@ def status(path: PathLike[str] | str) -> stat_result | None:
|
|
|
39
39
|
- When `path` ends with a separator or `.`, which says it's a directory, but
|
|
40
40
|
leads to a file.
|
|
41
41
|
- When `path` is empty, which names nothing.
|
|
42
|
+
- When `path` contains a null character.
|
|
42
43
|
|
|
43
44
|
The error names `path` as it was given.
|
|
44
45
|
|
|
@@ -68,10 +69,12 @@ def status(path: PathLike[str] | str) -> stat_result | None:
|
|
|
68
69
|
"""
|
|
69
70
|
# A path that ends like a directory is left to `resolve`, which raises when it
|
|
70
71
|
# leads to a file, because Windows' `stat` drops a trailing separator or `.`.
|
|
71
|
-
|
|
72
|
-
#
|
|
72
|
+
#
|
|
73
|
+
# ...and so is a relative path, because `stat` can still find `.` and `..` after the
|
|
73
74
|
# working directory is gone, and that mustn't pass.
|
|
74
|
-
|
|
75
|
+
#
|
|
76
|
+
# ...and so is a path that holds a null character.
|
|
77
|
+
if isabs(path) and not ends_like_directory(path) and "\0" not in fspath(path):
|
|
75
78
|
# A path that can be `stat`-ed already names the file that it resolves to, so
|
|
76
79
|
# it needn't be resolved. An error is left to the route below to describe.
|
|
77
80
|
with suppress(OSError):
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .can_chmod_deny_listing import can_chmod_deny_listing
|
|
2
|
+
from .can_chmod_deny_search import can_chmod_deny_search
|
|
3
|
+
from .can_chmod_deny_writing import can_chmod_deny_writing
|
|
4
|
+
from .is_treated_as_same_name import is_treated_as_same_name
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"can_chmod_deny_listing",
|
|
8
|
+
"can_chmod_deny_search",
|
|
9
|
+
"can_chmod_deny_writing",
|
|
10
|
+
"is_treated_as_same_name",
|
|
11
|
+
]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from os import listdir
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from shutil import rmtree
|
|
4
|
+
from stat import S_IRWXU, S_IWUSR, S_IXUSR
|
|
5
|
+
from tempfile import mkdtemp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def can_chmod_deny_listing(directory: Path) -> bool:
|
|
9
|
+
"""
|
|
10
|
+
Check whether removing a directory's "read" (`r`) permission with
|
|
11
|
+
[`chmod`][os.chmod] denies it to this process.
|
|
12
|
+
|
|
13
|
+
The read permission is required by listing a directory, like with
|
|
14
|
+
[`listdir`][os.listdir].
|
|
15
|
+
|
|
16
|
+
This function only checks reading, not search (opening or [`stat`][os.stat]-ing a
|
|
17
|
+
file within the directory) or writing (creating, renaming or deleting files within
|
|
18
|
+
it).
|
|
19
|
+
|
|
20
|
+
**Linux & macOS:** `False` for root, for a process that can override permissions
|
|
21
|
+
(like one with Linux's `CAP_DAC_OVERRIDE` or `CAP_DAC_READ_SEARCH` capability), and
|
|
22
|
+
on file systems that ignore permissions, like FAT.
|
|
23
|
+
|
|
24
|
+
**Windows:** Always `False`, because `chmod` only sets the read-only flag.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
directory: Path to the directory to interrogate.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
`True` when `chmod(0o300)` denies this process the read permission on a
|
|
31
|
+
directory, otherwise `False`.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
OSError: When the permission probe cannot be written or deleted.
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
```python
|
|
38
|
+
def test_raises_when_listing_denied(tmp_path: Path) -> None:
|
|
39
|
+
if not can_chmod_deny_listing(tmp_path):
|
|
40
|
+
skip("chmod can't deny listing here")
|
|
41
|
+
|
|
42
|
+
...
|
|
43
|
+
```
|
|
44
|
+
"""
|
|
45
|
+
probe_dir = Path(mkdtemp(dir=directory))
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
probe_dir.chmod(S_IWUSR | S_IXUSR)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
listdir(probe_dir)
|
|
52
|
+
|
|
53
|
+
except PermissionError:
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
finally:
|
|
57
|
+
probe_dir.chmod(S_IRWXU)
|
|
58
|
+
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
finally:
|
|
62
|
+
rmtree(probe_dir)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from shutil import rmtree
|
|
3
|
+
from stat import S_IMODE, S_IRGRP, S_IROTH, S_IRUSR, S_IRWXU, S_IXGRP, S_IXOTH, S_IXUSR
|
|
4
|
+
from tempfile import mkdtemp
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def can_chmod_deny_writing(directory: Path) -> bool:
|
|
8
|
+
"""
|
|
9
|
+
Check whether removing a directory's "write" (`w`) permission with
|
|
10
|
+
[`chmod`][os.chmod] denies it to this process.
|
|
11
|
+
|
|
12
|
+
The write permission is required by creating, renaming or deleting a file within a
|
|
13
|
+
directory.
|
|
14
|
+
|
|
15
|
+
This function only checks writing, not reading (listing the directory) or search
|
|
16
|
+
(opening or [`stat`][os.stat]-ing a file within it).
|
|
17
|
+
|
|
18
|
+
**Linux & macOS:** `False` for root, for a process that can override permissions
|
|
19
|
+
(like one with Linux's `CAP_DAC_OVERRIDE` capability), and on file systems that
|
|
20
|
+
ignore permissions, like FAT.
|
|
21
|
+
|
|
22
|
+
**Windows:** Always `False`, because `chmod` only sets the read-only flag, which
|
|
23
|
+
Windows doesn't honour on directories.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
directory: Path to the directory to interrogate.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
`True` when `chmod(0o555)` denies this process the write permission on a
|
|
30
|
+
directory, otherwise `False`.
|
|
31
|
+
|
|
32
|
+
Raises:
|
|
33
|
+
OSError: When the permission probe cannot be written or deleted.
|
|
34
|
+
|
|
35
|
+
Examples:
|
|
36
|
+
```python
|
|
37
|
+
def test_raises_when_writing_denied(tmp_path: Path) -> None:
|
|
38
|
+
if not can_chmod_deny_writing(tmp_path):
|
|
39
|
+
skip("chmod can't deny writing here")
|
|
40
|
+
|
|
41
|
+
...
|
|
42
|
+
```
|
|
43
|
+
"""
|
|
44
|
+
probe_dir = Path(mkdtemp(dir=directory))
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
original_mode = S_IMODE(probe_dir.stat().st_mode)
|
|
48
|
+
|
|
49
|
+
# chmod(0o555), which keeps every read and search bit rather than only the
|
|
50
|
+
# owner's.
|
|
51
|
+
#
|
|
52
|
+
# Linux's FAT driver silently ignores a chmod that removes a read or search bit
|
|
53
|
+
# that its mount options allow, but with the `rodir` option it honours one that
|
|
54
|
+
# removes every write bit and nothing else.
|
|
55
|
+
probe_dir.chmod(S_IRUSR | S_IXUSR | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
(probe_dir / "file").touch()
|
|
59
|
+
|
|
60
|
+
except PermissionError:
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
finally:
|
|
64
|
+
# The original mode plus every owner permission, which rmtree needs even if
|
|
65
|
+
# the umask left some out.
|
|
66
|
+
probe_dir.chmod(original_mode | S_IRWXU)
|
|
67
|
+
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
finally:
|
|
71
|
+
rmtree(probe_dir)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from pathlib import Path, PurePath
|
|
2
|
+
from tempfile import mkdtemp
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def is_treated_as_same_name(directory: Path, name: str, alt_name: str) -> bool:
|
|
6
|
+
"""
|
|
7
|
+
Check whether the file system treats two spellings as the same name.
|
|
8
|
+
|
|
9
|
+
For example:
|
|
10
|
+
|
|
11
|
+
- `README.md` and `readme.md`
|
|
12
|
+
- the NFC and NFD forms of `café.md`.
|
|
13
|
+
|
|
14
|
+
Some file systems choose for each directory how to compare names, and a new
|
|
15
|
+
directory takes its parent's choice. The probe is made in a new directory within
|
|
16
|
+
`directory`, so the answer holds for `directory` and for new directories made
|
|
17
|
+
within it.
|
|
18
|
+
|
|
19
|
+
File systems that ignore case or normalisation don't all fold the same characters,
|
|
20
|
+
so probe with the names that a test uses.
|
|
21
|
+
|
|
22
|
+
**Linux:** Usually `False` for a different case or Unicode normalisation, because
|
|
23
|
+
most file systems compare names byte for byte. FAT, though, ignores the case of
|
|
24
|
+
ASCII letters by default, and an ext4 directory with case folding turned on ignores
|
|
25
|
+
both case and normalisation.
|
|
26
|
+
|
|
27
|
+
**macOS:** `True` for a different case unless APFS or HFS+ was formatted as
|
|
28
|
+
case-sensitive, and for a different Unicode normalisation on APFS, HFS+, FAT and
|
|
29
|
+
exFAT.
|
|
30
|
+
|
|
31
|
+
**Windows:** `True` for a different case unless the directory is case-sensitive,
|
|
32
|
+
but `False` for a different Unicode normalisation, because Windows compares names
|
|
33
|
+
without normalising them.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
directory: Path to the directory to interrogate.
|
|
37
|
+
name: Name of the file to create.
|
|
38
|
+
alt_name: Another spelling of `name` to look for.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
`True` when `alt_name` names the file created as `name`, otherwise `False`.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
OSError: When the probe cannot be written or deleted.
|
|
45
|
+
ValueError: When `name` or `alt_name` is or contains a directory.
|
|
46
|
+
|
|
47
|
+
Examples:
|
|
48
|
+
```python
|
|
49
|
+
def test_identity_by_different_case(tmp_path: Path) -> None:
|
|
50
|
+
if not is_treated_as_same_name(tmp_path, "readme.md", "README.md"):
|
|
51
|
+
skip("This file system doesn't treat 'README.md' as 'readme.md'")
|
|
52
|
+
|
|
53
|
+
...
|
|
54
|
+
```
|
|
55
|
+
"""
|
|
56
|
+
for given in (name, alt_name):
|
|
57
|
+
if given in ("", ".", "..") or PurePath(given).name != given:
|
|
58
|
+
raise ValueError(f"{given!r} is or contains a directory")
|
|
59
|
+
|
|
60
|
+
probe_dir = Path(mkdtemp(dir=directory))
|
|
61
|
+
probe_file = probe_dir / name
|
|
62
|
+
alt_file = probe_dir / alt_name
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
probe_file.touch()
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
# On Windows, a name can lead somewhere other than the probe's file (like
|
|
69
|
+
# `NUL` to the null device) so check that `alt_name` names the probe's file
|
|
70
|
+
# — not only that something exists there.
|
|
71
|
+
#
|
|
72
|
+
# `exists()` goes first because `samefile()` raises for a name that's
|
|
73
|
+
# missing or that Windows rejects.
|
|
74
|
+
return alt_file.exists() and alt_file.samefile(probe_file)
|
|
75
|
+
|
|
76
|
+
finally:
|
|
77
|
+
probe_file.unlink()
|
|
78
|
+
|
|
79
|
+
finally:
|
|
80
|
+
# Removing the file and then the directory, rather than the whole tree, doesn't
|
|
81
|
+
# need the permission to list the directory, which the umask can leave out.
|
|
82
|
+
probe_dir.rmdir()
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import sys
|
|
2
3
|
|
|
3
4
|
from pytest import mark
|
|
4
5
|
|
|
6
|
+
IS_ROOT = sys.platform != "win32" and os.geteuid() == 0
|
|
7
|
+
"""Whether the tests are running as root on Linux or macOS."""
|
|
8
|
+
|
|
9
|
+
|
|
5
10
|
posix_only = mark.skipif(
|
|
6
11
|
sys.platform == "win32",
|
|
7
12
|
reason="POSIX-only behaviour",
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from pytest import mark, raises
|
|
6
|
+
|
|
7
|
+
from rootle.testing import can_chmod_deny_listing
|
|
8
|
+
from tests.marks import IS_ROOT, windows_only
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_can_chmod_deny_listing_cleans_up(tmp_path: Path) -> None:
|
|
12
|
+
"""Assert that `can_chmod_deny_listing` removes everything that it creates."""
|
|
13
|
+
can_chmod_deny_listing(tmp_path)
|
|
14
|
+
|
|
15
|
+
remaining = list(tmp_path.iterdir())
|
|
16
|
+
|
|
17
|
+
assert remaining == []
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_can_chmod_deny_listing_cleans_up_when_chmod_fails(tmp_path: Path) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Assert that `can_chmod_deny_listing` removes the probe when it isn't permitted to
|
|
23
|
+
change the probe's permissions, like on a file system owned by another user.
|
|
24
|
+
"""
|
|
25
|
+
with (
|
|
26
|
+
patch.object(Path, "chmod", side_effect=PermissionError),
|
|
27
|
+
raises(PermissionError),
|
|
28
|
+
):
|
|
29
|
+
can_chmod_deny_listing(tmp_path)
|
|
30
|
+
|
|
31
|
+
remaining = list(tmp_path.iterdir())
|
|
32
|
+
|
|
33
|
+
assert remaining == []
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mark.skipif(
|
|
37
|
+
not IS_ROOT,
|
|
38
|
+
reason="Expects to run as root on POSIX, which ignores read permission",
|
|
39
|
+
)
|
|
40
|
+
def test_can_chmod_deny_listing_is_false_for_root_on_posix(tmp_path: Path) -> None:
|
|
41
|
+
"""Assert that `can_chmod_deny_listing` is `False` for root on Linux and macOS."""
|
|
42
|
+
denied = can_chmod_deny_listing(tmp_path)
|
|
43
|
+
|
|
44
|
+
assert denied is False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@windows_only
|
|
48
|
+
def test_can_chmod_deny_listing_is_false_on_windows(tmp_path: Path) -> None:
|
|
49
|
+
"""
|
|
50
|
+
Assert that `can_chmod_deny_listing` is `False` on Windows, where `chmod` only sets
|
|
51
|
+
the read-only flag.
|
|
52
|
+
"""
|
|
53
|
+
denied = can_chmod_deny_listing(tmp_path)
|
|
54
|
+
|
|
55
|
+
assert denied is False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_can_chmod_deny_listing_is_false_when_chmod_is_ignored(tmp_path: Path) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Assert that `can_chmod_deny_listing` is `False` when `chmod` doesn't take effect,
|
|
61
|
+
like on a file system that ignores permissions.
|
|
62
|
+
"""
|
|
63
|
+
with patch.object(Path, "chmod"):
|
|
64
|
+
denied = can_chmod_deny_listing(tmp_path)
|
|
65
|
+
|
|
66
|
+
assert denied is False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_can_chmod_deny_listing_is_true_when_chmod_denies_listing(
|
|
70
|
+
tmp_path: Path,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Assert that `can_chmod_deny_listing` is `True` when `chmod` denies the read
|
|
74
|
+
permission.
|
|
75
|
+
"""
|
|
76
|
+
with (
|
|
77
|
+
patch.object(Path, "chmod"),
|
|
78
|
+
patch(
|
|
79
|
+
"rootle.testing.can_chmod_deny_listing.listdir",
|
|
80
|
+
side_effect=PermissionError,
|
|
81
|
+
),
|
|
82
|
+
):
|
|
83
|
+
denied = can_chmod_deny_listing(tmp_path)
|
|
84
|
+
|
|
85
|
+
assert denied is True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mark.skipif(
|
|
89
|
+
sys.platform == "win32" or IS_ROOT,
|
|
90
|
+
reason="chmod can't deny read permission on Windows, or to root",
|
|
91
|
+
)
|
|
92
|
+
def test_can_chmod_deny_listing_on_posix(tmp_path: Path) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Assert that `can_chmod_deny_listing` is `True` on Linux and macOS for a process
|
|
95
|
+
other than root.
|
|
96
|
+
"""
|
|
97
|
+
denied = can_chmod_deny_listing(tmp_path)
|
|
98
|
+
|
|
99
|
+
assert denied is True
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_can_chmod_deny_listing_raises_when_directory_does_not_exist(
|
|
103
|
+
tmp_path: Path,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Assert that `can_chmod_deny_listing` raises `FileNotFoundError` when the directory
|
|
107
|
+
to probe doesn't exist.
|
|
108
|
+
"""
|
|
109
|
+
missing = tmp_path / "missing"
|
|
110
|
+
|
|
111
|
+
with raises(FileNotFoundError):
|
|
112
|
+
can_chmod_deny_listing(missing)
|
|
@@ -1,14 +1,11 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import sys
|
|
3
2
|
from pathlib import Path
|
|
4
3
|
from unittest.mock import patch
|
|
5
4
|
|
|
6
|
-
from pytest import mark
|
|
5
|
+
from pytest import mark, raises
|
|
7
6
|
|
|
8
7
|
from rootle.testing import can_chmod_deny_search
|
|
9
|
-
from tests.marks import windows_only
|
|
10
|
-
|
|
11
|
-
IS_ROOT = sys.platform != "win32" and os.geteuid() == 0
|
|
8
|
+
from tests.marks import IS_ROOT, windows_only
|
|
12
9
|
|
|
13
10
|
|
|
14
11
|
def test_can_chmod_deny_search_cleans_up(tmp_path: Path) -> None:
|
|
@@ -20,6 +17,22 @@ def test_can_chmod_deny_search_cleans_up(tmp_path: Path) -> None:
|
|
|
20
17
|
assert remaining == []
|
|
21
18
|
|
|
22
19
|
|
|
20
|
+
def test_can_chmod_deny_search_cleans_up_when_chmod_fails(tmp_path: Path) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Assert that `can_chmod_deny_search` removes the probe when it isn't permitted to
|
|
23
|
+
change the probe's permissions, like on a file system owned by another user.
|
|
24
|
+
"""
|
|
25
|
+
with (
|
|
26
|
+
patch.object(Path, "chmod", side_effect=PermissionError),
|
|
27
|
+
raises(PermissionError),
|
|
28
|
+
):
|
|
29
|
+
can_chmod_deny_search(tmp_path)
|
|
30
|
+
|
|
31
|
+
remaining = list(tmp_path.iterdir())
|
|
32
|
+
|
|
33
|
+
assert remaining == []
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
@mark.skipif(
|
|
24
37
|
not IS_ROOT,
|
|
25
38
|
reason="Expects to run as root on POSIX, which ignores search permission",
|
|
@@ -60,7 +73,10 @@ def test_can_chmod_deny_search_is_true_when_chmod_denies_search(
|
|
|
60
73
|
Assert that `can_chmod_deny_search` is `True` when `chmod` denies the search
|
|
61
74
|
permission.
|
|
62
75
|
"""
|
|
63
|
-
with
|
|
76
|
+
with (
|
|
77
|
+
patch.object(Path, "chmod"),
|
|
78
|
+
patch.object(Path, "stat", side_effect=PermissionError),
|
|
79
|
+
):
|
|
64
80
|
denied = can_chmod_deny_search(tmp_path)
|
|
65
81
|
|
|
66
82
|
assert denied is True
|
|
@@ -78,3 +94,16 @@ def test_can_chmod_deny_search_on_posix(tmp_path: Path) -> None:
|
|
|
78
94
|
denied = can_chmod_deny_search(tmp_path)
|
|
79
95
|
|
|
80
96
|
assert denied is True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_can_chmod_deny_search_raises_when_directory_does_not_exist(
|
|
100
|
+
tmp_path: Path,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Assert that `can_chmod_deny_search` raises `FileNotFoundError` when the directory
|
|
104
|
+
to probe doesn't exist.
|
|
105
|
+
"""
|
|
106
|
+
missing = tmp_path / "missing"
|
|
107
|
+
|
|
108
|
+
with raises(FileNotFoundError):
|
|
109
|
+
can_chmod_deny_search(missing)
|