exist-shell 0.1.0__py3-none-any.whl
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.
- exist_shell/__init__.py +5 -0
- exist_shell/cache.py +186 -0
- exist_shell/client/__init__.py +19 -0
- exist_shell/client/_base.py +65 -0
- exist_shell/client/_collections.py +158 -0
- exist_shell/client/_documents.py +134 -0
- exist_shell/client/_groups.py +116 -0
- exist_shell/client/_permissions.py +172 -0
- exist_shell/client/_queries.py +37 -0
- exist_shell/client/_users.py +158 -0
- exist_shell/commands/__init__.py +1 -0
- exist_shell/commands/cat.py +51 -0
- exist_shell/commands/chmod.py +236 -0
- exist_shell/commands/chown.py +121 -0
- exist_shell/commands/collection.py +189 -0
- exist_shell/commands/cp.py +142 -0
- exist_shell/commands/edit.py +85 -0
- exist_shell/commands/exec.py +65 -0
- exist_shell/commands/group.py +183 -0
- exist_shell/commands/ls.py +66 -0
- exist_shell/commands/mkdir.py +24 -0
- exist_shell/commands/mv.py +124 -0
- exist_shell/commands/put.py +63 -0
- exist_shell/commands/rm.py +23 -0
- exist_shell/commands/server.py +114 -0
- exist_shell/commands/sync.py +767 -0
- exist_shell/commands/user.py +300 -0
- exist_shell/completions.py +221 -0
- exist_shell/config.py +233 -0
- exist_shell/exceptions.py +68 -0
- exist_shell/main.py +64 -0
- exist_shell/models.py +61 -0
- exist_shell/utils.py +179 -0
- exist_shell/xquery.py +267 -0
- exist_shell-0.1.0.dist-info/METADATA +10 -0
- exist_shell-0.1.0.dist-info/RECORD +38 -0
- exist_shell-0.1.0.dist-info/WHEEL +4 -0
- exist_shell-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Permissions mixin — ownership and mode operations."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
|
|
5
|
+
from exist_shell.client._queries import QueryMixin
|
|
6
|
+
from exist_shell.utils import xq_escape
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _mode_str_to_int(mode_str: str) -> int:
|
|
10
|
+
"""Convert a POSIX mode string to an integer mode value.
|
|
11
|
+
|
|
12
|
+
Handles 9-character strings (``"rwxr-xr-x"``) as well as 10-character
|
|
13
|
+
strings with a leading type character (``"drwxr-xr-x"``). Special bits
|
|
14
|
+
(setuid ``s/S``, setgid ``s/S``, sticky ``t/T``) are decoded correctly.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
mode_str: Mode string to convert.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Integer mode value (0–0o7777).
|
|
21
|
+
"""
|
|
22
|
+
if len(mode_str) >= 10:
|
|
23
|
+
mode_str = mode_str[1:]
|
|
24
|
+
if len(mode_str) < 9:
|
|
25
|
+
return 0
|
|
26
|
+
mode_str = mode_str[:9]
|
|
27
|
+
value = 0
|
|
28
|
+
# user
|
|
29
|
+
if mode_str[0] == "r":
|
|
30
|
+
value |= 0o400
|
|
31
|
+
if mode_str[1] == "w":
|
|
32
|
+
value |= 0o200
|
|
33
|
+
if mode_str[2] in ("x", "s"):
|
|
34
|
+
value |= 0o100
|
|
35
|
+
if mode_str[2] in ("s", "S"):
|
|
36
|
+
value |= 0o4000
|
|
37
|
+
# group
|
|
38
|
+
if mode_str[3] == "r":
|
|
39
|
+
value |= 0o040
|
|
40
|
+
if mode_str[4] == "w":
|
|
41
|
+
value |= 0o020
|
|
42
|
+
if mode_str[5] in ("x", "s"):
|
|
43
|
+
value |= 0o010
|
|
44
|
+
if mode_str[5] in ("s", "S"):
|
|
45
|
+
value |= 0o2000
|
|
46
|
+
# other
|
|
47
|
+
if mode_str[6] == "r":
|
|
48
|
+
value |= 0o004
|
|
49
|
+
if mode_str[7] == "w":
|
|
50
|
+
value |= 0o002
|
|
51
|
+
if mode_str[8] in ("x", "t"):
|
|
52
|
+
value |= 0o001
|
|
53
|
+
if mode_str[8] in ("t", "T"):
|
|
54
|
+
value |= 0o1000
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _int_to_mode_str(mode: int) -> str:
|
|
59
|
+
"""Convert an integer mode value to a 9-character POSIX mode string.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
mode: Integer mode value (0–0o7777).
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
9-character string like ``"rwxr-xr-x"``.
|
|
66
|
+
"""
|
|
67
|
+
chars: list[str] = []
|
|
68
|
+
# user
|
|
69
|
+
chars.append("r" if mode & 0o400 else "-")
|
|
70
|
+
chars.append("w" if mode & 0o200 else "-")
|
|
71
|
+
if mode & 0o4000:
|
|
72
|
+
chars.append("s" if mode & 0o100 else "S")
|
|
73
|
+
else:
|
|
74
|
+
chars.append("x" if mode & 0o100 else "-")
|
|
75
|
+
# group
|
|
76
|
+
chars.append("r" if mode & 0o040 else "-")
|
|
77
|
+
chars.append("w" if mode & 0o020 else "-")
|
|
78
|
+
if mode & 0o2000:
|
|
79
|
+
chars.append("s" if mode & 0o010 else "S")
|
|
80
|
+
else:
|
|
81
|
+
chars.append("x" if mode & 0o010 else "-")
|
|
82
|
+
# other
|
|
83
|
+
chars.append("r" if mode & 0o004 else "-")
|
|
84
|
+
chars.append("w" if mode & 0o002 else "-")
|
|
85
|
+
if mode & 0o1000:
|
|
86
|
+
chars.append("t" if mode & 0o001 else "T")
|
|
87
|
+
else:
|
|
88
|
+
chars.append("x" if mode & 0o001 else "-")
|
|
89
|
+
return "".join(chars)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class PermissionMixin(QueryMixin):
|
|
93
|
+
"""Mixin providing ownership and mode operations against the eXist REST API."""
|
|
94
|
+
|
|
95
|
+
def chown_resource(self, path: str, owner: str | None, group: str | None) -> None:
|
|
96
|
+
"""Change the owner and/or group of a document or collection.
|
|
97
|
+
|
|
98
|
+
Validates that the specified owner and group exist on the server before
|
|
99
|
+
applying the change, in a single round-trip. Either ``owner`` or
|
|
100
|
+
``group`` (or both) must be non-``None``.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
path: Full eXist path starting with ``/db/``.
|
|
104
|
+
owner: New owner username, or ``None`` to leave unchanged.
|
|
105
|
+
group: New group name, or ``None`` to leave unchanged.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
ExistConnectionError: If the server cannot be reached.
|
|
109
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
110
|
+
ExistQueryError: If the user or group does not exist, the path is
|
|
111
|
+
not found, permission is denied, or the query otherwise fails.
|
|
112
|
+
"""
|
|
113
|
+
safe_path = xq_escape(path)
|
|
114
|
+
validation: list[str] = []
|
|
115
|
+
changes: list[str] = []
|
|
116
|
+
|
|
117
|
+
if owner:
|
|
118
|
+
s = xq_escape(owner)
|
|
119
|
+
validation.append(
|
|
120
|
+
f'if (not(sm:user-exists("{s}"))) then error((), "User not found: {s}") else ()'
|
|
121
|
+
)
|
|
122
|
+
changes.append(f'sm:chown(xs:anyURI("{safe_path}"), "{s}")')
|
|
123
|
+
|
|
124
|
+
if group:
|
|
125
|
+
s = xq_escape(group)
|
|
126
|
+
validation.append(
|
|
127
|
+
f'if (not(sm:group-exists("{s}"))) then error((), "Group not found: {s}") else ()'
|
|
128
|
+
)
|
|
129
|
+
changes.append(f'sm:chgrp(xs:anyURI("{safe_path}"), "{s}")')
|
|
130
|
+
|
|
131
|
+
clauses = validation + changes
|
|
132
|
+
query = 'xquery version "3.1"; (' + ", ".join(clauses) + ", ())"
|
|
133
|
+
self.execute_query(query)
|
|
134
|
+
|
|
135
|
+
def get_permissions(self, path: str) -> int:
|
|
136
|
+
"""Return the POSIX mode bits for a document or collection as an integer.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
path: Full eXist path starting with ``/db/``.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Integer mode value (0–0o7777).
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ExistConnectionError: If the server cannot be reached.
|
|
146
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
147
|
+
ExistQueryError: If the path does not exist or the query fails.
|
|
148
|
+
"""
|
|
149
|
+
safe_path = xq_escape(path)
|
|
150
|
+
query = f'xquery version "3.1"; sm:get-permissions(xs:anyURI("{safe_path}"))'
|
|
151
|
+
raw = self.execute_query(query)
|
|
152
|
+
el = ET.fromstring(raw)
|
|
153
|
+
mode_str = el.get("mode", "---------")
|
|
154
|
+
return _mode_str_to_int(mode_str)
|
|
155
|
+
|
|
156
|
+
def chmod_resource(self, path: str, mode: int) -> None:
|
|
157
|
+
"""Change the POSIX mode of a document or collection.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
path: Full eXist path starting with ``/db/``.
|
|
161
|
+
mode: New mode as an integer (0–0o7777).
|
|
162
|
+
|
|
163
|
+
Raises:
|
|
164
|
+
ExistConnectionError: If the server cannot be reached.
|
|
165
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
166
|
+
ExistQueryError: If the path does not exist, permission is denied,
|
|
167
|
+
or the query otherwise fails.
|
|
168
|
+
"""
|
|
169
|
+
safe_path = xq_escape(path)
|
|
170
|
+
mode_str = _int_to_mode_str(mode)
|
|
171
|
+
query = f'xquery version "3.1"; sm:chmod(xs:anyURI("{safe_path}"), "{mode_str}")'
|
|
172
|
+
self.execute_query(query)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Query mixin — XQuery execution."""
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from exist_shell.client._base import ExistClientBase
|
|
6
|
+
from exist_shell.exceptions import ExistAuthError, ExistConnectionError, ExistQueryError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class QueryMixin(ExistClientBase):
|
|
10
|
+
"""Mixin providing XQuery execution against the eXist REST API."""
|
|
11
|
+
|
|
12
|
+
def execute_query(self, query: str, context: str = "/db") -> str:
|
|
13
|
+
"""Execute an XQuery string and return the raw response body.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
query: XQuery source code to execute.
|
|
17
|
+
context: The eXist collection path used as the query context.
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Raw response text from the server.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ExistConnectionError: If the server cannot be reached.
|
|
24
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
25
|
+
ExistQueryError: If the server returns HTTP 400 or 500 (query error).
|
|
26
|
+
"""
|
|
27
|
+
url = self._url(context)
|
|
28
|
+
try:
|
|
29
|
+
r = self._http.post(url, data={"_query": query, "_wrap": "no"})
|
|
30
|
+
except httpx.RequestError as e:
|
|
31
|
+
raise ExistConnectionError(url, e) from e
|
|
32
|
+
if r.status_code == 401:
|
|
33
|
+
raise ExistAuthError(url)
|
|
34
|
+
if r.status_code in (400, 500):
|
|
35
|
+
raise ExistQueryError(r.text.strip())
|
|
36
|
+
r.raise_for_status()
|
|
37
|
+
return r.text
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Users mixin — user account operations."""
|
|
2
|
+
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
|
|
5
|
+
from exist_shell.client._queries import QueryMixin
|
|
6
|
+
from exist_shell.models import UserEntry, UserInfo
|
|
7
|
+
from exist_shell.utils import xq_escape
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class UserMixin(QueryMixin):
|
|
11
|
+
"""Mixin providing user account operations against the eXist REST API."""
|
|
12
|
+
|
|
13
|
+
def list_users(self) -> list[UserEntry]:
|
|
14
|
+
"""List all user accounts and their group memberships.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
List of UserEntry objects sorted alphabetically by username.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
ExistConnectionError: If the server cannot be reached.
|
|
21
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
22
|
+
ExistQueryError: If the XQuery fails.
|
|
23
|
+
"""
|
|
24
|
+
query = (
|
|
25
|
+
'xquery version "3.1"; '
|
|
26
|
+
'<users>{ '
|
|
27
|
+
'for $u in sm:list-users() '
|
|
28
|
+
'let $groups := sm:get-user-groups($u) '
|
|
29
|
+
'order by $u '
|
|
30
|
+
'return <user name="{$u}" groups="{string-join($groups, ",")}"/> '
|
|
31
|
+
'}</users>'
|
|
32
|
+
)
|
|
33
|
+
raw = self.execute_query(query)
|
|
34
|
+
root = ET.fromstring(raw)
|
|
35
|
+
result: list[UserEntry] = []
|
|
36
|
+
for el in root.findall("user"):
|
|
37
|
+
groups_str = el.get("groups", "")
|
|
38
|
+
groups = [g for g in groups_str.split(",") if g]
|
|
39
|
+
result.append(UserEntry(username=el.get("name", ""), groups=groups))
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
def get_user(self, username: str) -> UserInfo:
|
|
43
|
+
"""Get detailed information about a single user account.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
username: The account name to look up.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
UserInfo with the account's username, full name, group memberships,
|
|
50
|
+
and whether the account is enabled.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ExistConnectionError: If the server cannot be reached.
|
|
54
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
55
|
+
ExistQueryError: If the user does not exist or the query fails.
|
|
56
|
+
"""
|
|
57
|
+
safe = xq_escape(username)
|
|
58
|
+
query = (
|
|
59
|
+
'xquery version "3.1"; '
|
|
60
|
+
f'if (not(sm:user-exists("{safe}"))) '
|
|
61
|
+
f'then error((), "User not found: {safe}") '
|
|
62
|
+
f'else '
|
|
63
|
+
f'let $groups := sm:get-user-groups("{safe}") '
|
|
64
|
+
f'let $enabled := sm:is-account-enabled("{safe}") '
|
|
65
|
+
f'let $fullname := (sm:get-account-metadata("{safe}", xs:anyURI("http://axschema.org/namePerson")), "")[1] '
|
|
66
|
+
f'return <user '
|
|
67
|
+
f'name="{safe}" '
|
|
68
|
+
f'fullname="{{$fullname}}" '
|
|
69
|
+
f'enabled="{{$enabled}}" '
|
|
70
|
+
f'groups="{{string-join($groups, \',\')}}" />'
|
|
71
|
+
)
|
|
72
|
+
raw = self.execute_query(query)
|
|
73
|
+
el = ET.fromstring(raw)
|
|
74
|
+
groups_str = el.get("groups", "")
|
|
75
|
+
groups = [g for g in groups_str.split(",") if g]
|
|
76
|
+
return UserInfo(
|
|
77
|
+
username=el.get("name", username),
|
|
78
|
+
full_name=el.get("fullname") or None,
|
|
79
|
+
groups=groups,
|
|
80
|
+
enabled=(el.get("enabled", "true").lower() == "true"),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def user_exists(self, username: str) -> bool:
|
|
84
|
+
"""Check whether a user account exists on the server.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
username: The account name to check.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
True if the account exists, False otherwise.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ExistConnectionError: If the server cannot be reached.
|
|
94
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
95
|
+
ExistQueryError: If the query fails.
|
|
96
|
+
"""
|
|
97
|
+
safe = xq_escape(username)
|
|
98
|
+
query = f'xquery version "3.1"; sm:user-exists("{safe}")'
|
|
99
|
+
return self.execute_query(query).strip() == "true"
|
|
100
|
+
|
|
101
|
+
def create_user(self, username: str, password: str, groups: list[str]) -> None:
|
|
102
|
+
"""Create a new user account.
|
|
103
|
+
|
|
104
|
+
The first entry in groups becomes the primary group.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
username: The new account name.
|
|
108
|
+
password: The plaintext password.
|
|
109
|
+
groups: One or more group names; the first is the primary group.
|
|
110
|
+
|
|
111
|
+
Raises:
|
|
112
|
+
ExistConnectionError: If the server cannot be reached.
|
|
113
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
114
|
+
ExistQueryError: If the account already exists or the query fails.
|
|
115
|
+
"""
|
|
116
|
+
safe_user = xq_escape(username)
|
|
117
|
+
safe_pass = xq_escape(password)
|
|
118
|
+
groups_xq = ", ".join(f'"{xq_escape(g)}"' for g in groups)
|
|
119
|
+
query = (
|
|
120
|
+
'xquery version "3.1"; '
|
|
121
|
+
f'sm:create-account("{safe_user}", "{safe_pass}", ({groups_xq}))'
|
|
122
|
+
)
|
|
123
|
+
self.execute_query(query)
|
|
124
|
+
|
|
125
|
+
def delete_user(self, username: str) -> None:
|
|
126
|
+
"""Remove a user account from the server.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
username: The account name to remove.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ExistConnectionError: If the server cannot be reached.
|
|
133
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
134
|
+
ExistQueryError: If the user does not exist or the query fails.
|
|
135
|
+
"""
|
|
136
|
+
safe = xq_escape(username)
|
|
137
|
+
query = f'xquery version "3.1"; sm:remove-account("{safe}")'
|
|
138
|
+
self.execute_query(query)
|
|
139
|
+
|
|
140
|
+
def change_password(self, username: str, password: str) -> None:
|
|
141
|
+
"""Change the password of an existing user account.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
username: The account name.
|
|
145
|
+
password: The new plaintext password.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ExistConnectionError: If the server cannot be reached.
|
|
149
|
+
ExistAuthError: If the server returns HTTP 401.
|
|
150
|
+
ExistQueryError: If the user does not exist or the query fails.
|
|
151
|
+
"""
|
|
152
|
+
safe_user = xq_escape(username)
|
|
153
|
+
safe_pass = xq_escape(password)
|
|
154
|
+
query = (
|
|
155
|
+
'xquery version "3.1"; '
|
|
156
|
+
f'sm:passwd("{safe_user}", "{safe_pass}")'
|
|
157
|
+
)
|
|
158
|
+
self.execute_query(query)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI subcommands for exsh."""
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""cat command — print document content from an eXist collection path."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from exist_shell.client import ExistClient
|
|
8
|
+
from exist_shell.completions import collection_target_completer
|
|
9
|
+
from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
|
|
10
|
+
|
|
11
|
+
_TEXT_TYPES = {"application/xml", "application/json", "application/javascript"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_text(mime_type: str) -> bool:
|
|
15
|
+
"""Return True if the MIME type should be treated as human-readable text.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
mime_type: The MIME type string (without parameters).
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
True if the content is printable text, False if binary.
|
|
22
|
+
"""
|
|
23
|
+
return mime_type.startswith("text/") or mime_type in _TEXT_TYPES
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cat(
|
|
27
|
+
target: str = typer.Argument(
|
|
28
|
+
help="Collection and document path: <nick>:<path>.",
|
|
29
|
+
autocompletion=collection_target_completer("resource"),
|
|
30
|
+
),
|
|
31
|
+
raw: bool = typer.Option(False, "--raw", help="Write raw bytes to stdout even for binary content."),
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Print the content of a document to stdout."""
|
|
34
|
+
nick, path = parse_target(target)
|
|
35
|
+
collection, server, full_path = resolve_collection(nick, path)
|
|
36
|
+
|
|
37
|
+
with handle_exist_errors(path, nick, collection.server_nick):
|
|
38
|
+
with ExistClient(server) as client:
|
|
39
|
+
result = client.get_document(full_path)
|
|
40
|
+
|
|
41
|
+
if not raw and not _is_text(result.mime_type):
|
|
42
|
+
typer.echo(
|
|
43
|
+
f"Error: '{path}' is binary ({result.mime_type}). Use --raw to write bytes to stdout.",
|
|
44
|
+
err=True,
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
if raw:
|
|
49
|
+
sys.stdout.buffer.write(result.content)
|
|
50
|
+
else:
|
|
51
|
+
sys.stdout.write(result.content.decode())
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""chmod command — change POSIX permissions on a document or collection."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from exist_shell.client import ExistClient
|
|
9
|
+
from exist_shell.completions import collection_target_completer
|
|
10
|
+
from exist_shell.exceptions import ExistQueryError
|
|
11
|
+
from exist_shell.models import CollectionEntry
|
|
12
|
+
from exist_shell.utils import handle_exist_errors, parse_target, resolve_collection
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Per-who bit tables used by the symbolic-mode parser
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
# Maps (who, perm_char) → the bit to set/clear
|
|
19
|
+
_WHO_PERM: dict[str, dict[str, int]] = {
|
|
20
|
+
"u": {"r": 0o400, "w": 0o200, "x": 0o100, "s": 0o4000, "t": 0},
|
|
21
|
+
"g": {"r": 0o040, "w": 0o020, "x": 0o010, "s": 0o2000, "t": 0},
|
|
22
|
+
"o": {"r": 0o004, "w": 0o002, "x": 0o001, "s": 0, "t": 0o1000},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Bits to clear when applying the '=' operator for a given who
|
|
26
|
+
_WHO_CLEAR: dict[str, int] = {
|
|
27
|
+
"u": 0o4700, # setuid + user rwx
|
|
28
|
+
"g": 0o2070, # setgid + group rwx
|
|
29
|
+
"o": 0o1007, # sticky + other rwx
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Mode parsing helpers
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
_OCTAL_RE = re.compile(r"0?[0-7]{1,4}$")
|
|
37
|
+
_SYMBOLIC_RE = re.compile(r"[ugoa]*[+\-=][rwxst]*(,[ugoa]*[+\-=][rwxst]*)*$")
|
|
38
|
+
|
|
39
|
+
# Type alias: either a resolved integer mode or a callable that maps the
|
|
40
|
+
# current mode (int) to the new mode (int).
|
|
41
|
+
_ModeSpec = int | Callable[[int], int]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _is_octal_mode(mode: str) -> bool:
|
|
45
|
+
"""Return ``True`` if *mode* looks like an octal mode string.
|
|
46
|
+
|
|
47
|
+
Accepts strings of 1–4 octal digits with an optional leading ``0``
|
|
48
|
+
(e.g. ``"0755"``, ``"644"``, ``"7"``, ``"4755"``).
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
mode: The mode string from the CLI.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
``True`` when the string is a valid octal mode.
|
|
55
|
+
"""
|
|
56
|
+
return bool(_OCTAL_RE.match(mode))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_octal_mode(mode: str) -> int:
|
|
60
|
+
"""Parse an octal mode string to an integer.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
mode: An octal string like ``"0755"`` or ``"644"``.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Integer mode value (0–0o7777).
|
|
67
|
+
"""
|
|
68
|
+
return int(mode, 8)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _apply_symbolic_mode(mode_str: str, current: int) -> int:
|
|
72
|
+
"""Apply a symbolic mode string to a current integer mode.
|
|
73
|
+
|
|
74
|
+
Parses a comma-separated list of clauses of the form
|
|
75
|
+
``[ugoa]*[+-=][rwxst]*``. An empty *who* prefix is treated as ``a``
|
|
76
|
+
(all).
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
mode_str: Symbolic mode string (e.g. ``"u+x"``, ``"go-w"``,
|
|
80
|
+
``"a=rw"``, ``"u+x,go-r"``).
|
|
81
|
+
current: Current mode as an integer.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
New mode as an integer.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValueError: If the mode string contains an invalid clause.
|
|
88
|
+
"""
|
|
89
|
+
result = current
|
|
90
|
+
for clause in mode_str.split(","):
|
|
91
|
+
m = re.fullmatch(r"([ugoa]*)([+\-=])([rwxst]*)", clause.strip())
|
|
92
|
+
if not m:
|
|
93
|
+
raise ValueError(f"invalid symbolic mode clause: '{clause}'")
|
|
94
|
+
who_str, op, perms_str = m.groups()
|
|
95
|
+
|
|
96
|
+
# Empty who or explicit 'a' expands to all three categories.
|
|
97
|
+
whos: list[str] = list(who_str) if (who_str and who_str != "a") else ["u", "g", "o"]
|
|
98
|
+
|
|
99
|
+
# Accumulate the bits that change.
|
|
100
|
+
change_bits = 0
|
|
101
|
+
for w in whos:
|
|
102
|
+
for p in perms_str:
|
|
103
|
+
change_bits |= _WHO_PERM[w].get(p, 0)
|
|
104
|
+
|
|
105
|
+
if op == "+":
|
|
106
|
+
result |= change_bits
|
|
107
|
+
elif op == "-":
|
|
108
|
+
result &= ~change_bits
|
|
109
|
+
else: # op == "="
|
|
110
|
+
# Clear all bits belonging to the specified who, then set new ones.
|
|
111
|
+
clear_mask = 0
|
|
112
|
+
for w in whos:
|
|
113
|
+
clear_mask |= _WHO_CLEAR[w]
|
|
114
|
+
result = (result & ~clear_mask) | change_bits
|
|
115
|
+
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Internal helpers shared by single and recursive apply
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_mode(client: ExistClient, path: str, mode_spec: _ModeSpec) -> int:
|
|
125
|
+
"""Return the concrete integer mode to apply at *path*.
|
|
126
|
+
|
|
127
|
+
For an integer *mode_spec* this is a no-op. For a callable, it first
|
|
128
|
+
queries the current permissions of *path* and passes them through.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
client: Active ExistClient.
|
|
132
|
+
path: Full eXist path to query (only used for symbolic specs).
|
|
133
|
+
mode_spec: Resolved integer or symbolic-mode callable.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Integer mode to apply.
|
|
137
|
+
"""
|
|
138
|
+
if not isinstance(mode_spec, int):
|
|
139
|
+
current = client.get_permissions(path)
|
|
140
|
+
return mode_spec(current)
|
|
141
|
+
return mode_spec
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _chmod_tree(client: ExistClient, path: str, mode_spec: _ModeSpec) -> int:
|
|
145
|
+
"""Recursively change permissions of a collection and all its contents.
|
|
146
|
+
|
|
147
|
+
For an octal (integer) *mode_spec* the same absolute mode is applied to
|
|
148
|
+
every item. For a symbolic *mode_spec* the relative change is applied
|
|
149
|
+
independently to each item's current permissions.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
client: Active ExistClient.
|
|
153
|
+
path: Full eXist path to the root collection.
|
|
154
|
+
mode_spec: Resolved integer mode or symbolic-mode callable.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Total number of resources and collections whose mode was changed.
|
|
158
|
+
"""
|
|
159
|
+
client.chmod_resource(path, _resolve_mode(client, path, mode_spec))
|
|
160
|
+
count = 1
|
|
161
|
+
for item in client.list_collection(path):
|
|
162
|
+
child = f"{path}/{item.name}"
|
|
163
|
+
if isinstance(item, CollectionEntry):
|
|
164
|
+
count += _chmod_tree(client, child, mode_spec)
|
|
165
|
+
else:
|
|
166
|
+
client.chmod_resource(child, _resolve_mode(client, child, mode_spec))
|
|
167
|
+
count += 1
|
|
168
|
+
return count
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# chmod command
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def chmod(
|
|
177
|
+
mode: str = typer.Argument(
|
|
178
|
+
help=(
|
|
179
|
+
"Permission mode: octal (e.g. '0755', '644') or symbolic "
|
|
180
|
+
"(e.g. 'u+x', 'go-w', 'a=rw', 'u+x,go-r')."
|
|
181
|
+
),
|
|
182
|
+
),
|
|
183
|
+
target: str = typer.Argument(
|
|
184
|
+
help="Remote path: <nick>:<path>.",
|
|
185
|
+
autocompletion=collection_target_completer("any"),
|
|
186
|
+
),
|
|
187
|
+
recursive: bool = typer.Option(
|
|
188
|
+
False, "--recursive", "-R",
|
|
189
|
+
help="Apply recursively to all contents of a collection.",
|
|
190
|
+
),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Change POSIX permissions on a document or collection.
|
|
193
|
+
|
|
194
|
+
Accepts both octal (e.g. ``0755``, ``644``) and symbolic (e.g. ``u+x``,
|
|
195
|
+
``go-w``, ``a=rw``) mode specifications.
|
|
196
|
+
|
|
197
|
+
Raises:
|
|
198
|
+
typer.Exit: On invalid mode, unknown collection, or server error.
|
|
199
|
+
"""
|
|
200
|
+
# Determine and validate the mode specification.
|
|
201
|
+
mode_spec: _ModeSpec
|
|
202
|
+
if _is_octal_mode(mode):
|
|
203
|
+
mode_spec = _parse_octal_mode(mode)
|
|
204
|
+
elif _SYMBOLIC_RE.match(mode):
|
|
205
|
+
captured = mode
|
|
206
|
+
mode_spec = lambda current, _m=captured: _apply_symbolic_mode(_m, current)
|
|
207
|
+
else:
|
|
208
|
+
typer.echo(
|
|
209
|
+
f"Error: invalid mode '{mode}'. "
|
|
210
|
+
"Use octal (e.g. '0755') or symbolic (e.g. 'u+x', 'go-w').",
|
|
211
|
+
err=True,
|
|
212
|
+
)
|
|
213
|
+
raise typer.Exit(1)
|
|
214
|
+
|
|
215
|
+
nick, path = parse_target(target, path_required=False)
|
|
216
|
+
collection, server, full_path = resolve_collection(nick, path)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
with handle_exist_errors(path, nick, collection.server_nick):
|
|
220
|
+
with ExistClient(server) as client:
|
|
221
|
+
if recursive:
|
|
222
|
+
if not client.is_collection(full_path):
|
|
223
|
+
typer.echo(
|
|
224
|
+
f"Error: '{path}' is not a collection. "
|
|
225
|
+
"Omit -R to chmod a single document.",
|
|
226
|
+
err=True,
|
|
227
|
+
)
|
|
228
|
+
raise typer.Exit(1)
|
|
229
|
+
count = _chmod_tree(client, full_path, mode_spec)
|
|
230
|
+
typer.echo(f"Permissions of '{path}' updated ({count} items).")
|
|
231
|
+
else:
|
|
232
|
+
client.chmod_resource(full_path, _resolve_mode(client, full_path, mode_spec))
|
|
233
|
+
typer.echo(f"Permissions of '{path}' updated.")
|
|
234
|
+
except ExistQueryError as e:
|
|
235
|
+
typer.echo(f"Error: {e}", err=True)
|
|
236
|
+
raise typer.Exit(1)
|