sshfc 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.
- sshfc/__init__.py +4 -0
- sshfc/cli.py +160 -0
- sshfc/core/__init__.py +30 -0
- sshfc/core/audit.py +77 -0
- sshfc/core/check.py +104 -0
- sshfc/core/config.py +329 -0
- sshfc/core/executor.py +58 -0
- sshfc/core/parser.py +240 -0
- sshfc/core/spec.py +87 -0
- sshfc/core/validators.py +115 -0
- sshfc/samples/podman-lifecycle.toml +23 -0
- sshfc/samples/podman-readonly.toml +74 -0
- sshfc/samples/system-info.toml +17 -0
- sshfc-0.1.0.dist-info/METADATA +312 -0
- sshfc-0.1.0.dist-info/RECORD +18 -0
- sshfc-0.1.0.dist-info/WHEEL +4 -0
- sshfc-0.1.0.dist-info/entry_points.txt +3 -0
- sshfc-0.1.0.dist-info/licenses/LICENSE +21 -0
sshfc/core/config.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Load the allowed command set from TOML policy files.
|
|
5
|
+
|
|
6
|
+
A policy is a single ``.toml`` file or a directory of them (read in sorted order; only ``*.toml`` names not starting
|
|
7
|
+
with ``.``). Each file has a flat ``[commands."<path>"]`` table per command, keyed by the full command path::
|
|
8
|
+
|
|
9
|
+
[commands.podman]
|
|
10
|
+
help = "Manage pods, containers and images."
|
|
11
|
+
run = "/usr/bin/podman"
|
|
12
|
+
|
|
13
|
+
[commands."podman ps"]
|
|
14
|
+
help = "List containers."
|
|
15
|
+
options."--format" = { help = "Go template.", go_template = ["ID", "Image"] }
|
|
16
|
+
|
|
17
|
+
Merging is order-independent: each command path is defined by one file, except that the same path may appear in
|
|
18
|
+
several files if every definition is identical (so independent files can share a parent such as ``podman``).
|
|
19
|
+
``accept``/``reject`` examples aren't part of the definition; they're combined.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
import stat
|
|
26
|
+
import tomllib
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Final
|
|
30
|
+
|
|
31
|
+
from sshfc.core.parser import CommandRoot, SpecError
|
|
32
|
+
from sshfc.core.spec import Command, Flag, Option, Positional
|
|
33
|
+
from sshfc.core.validators import NAMED, GoTemplateFormat, Pattern
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Collection
|
|
37
|
+
|
|
38
|
+
from sshfc.core.spec import Validator
|
|
39
|
+
|
|
40
|
+
DEFAULT_PATH: Final = Path("/etc/sshfc/commands.d")
|
|
41
|
+
|
|
42
|
+
_ROOT_UID: Final = 0
|
|
43
|
+
_NAME: Final = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]*")
|
|
44
|
+
_TOP_LEVEL_KEYS: Final = frozenset({"commands"})
|
|
45
|
+
_COMMAND_KEYS: Final = frozenset(
|
|
46
|
+
{"help", "run", "end_of_options", "options", "flags", "positionals", "accept", "reject"}
|
|
47
|
+
)
|
|
48
|
+
_EXAMPLE_KEYS: Final = frozenset({"accept", "reject"})
|
|
49
|
+
_VALIDATOR_KEYS: Final = frozenset({"validator", "pattern", "go_template"})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ConfigError(Exception):
|
|
53
|
+
"""Raised when the policy can't be loaded: missing, insecure, malformed, or inconsistent."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Policy:
|
|
58
|
+
"""A loaded policy."""
|
|
59
|
+
|
|
60
|
+
files: list[Path]
|
|
61
|
+
commands: list[Command]
|
|
62
|
+
root: CommandRoot
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class _Entry:
|
|
67
|
+
"""One command path, merged across files."""
|
|
68
|
+
|
|
69
|
+
definition: dict[str, Any]
|
|
70
|
+
sources: list[str]
|
|
71
|
+
accept: list[Any] = field(default_factory=list)
|
|
72
|
+
reject: list[Any] = field(default_factory=list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def policy_files(path: Path) -> list[Path]:
|
|
76
|
+
"""Return the policy files at path (a file, or a directory of ``*.toml`` files)."""
|
|
77
|
+
if path.is_dir():
|
|
78
|
+
return sorted(p for p in path.iterdir() if p.name.endswith(".toml") and not p.name.startswith("."))
|
|
79
|
+
if path.is_file():
|
|
80
|
+
return [path]
|
|
81
|
+
msg = f"{path}: no such file or directory"
|
|
82
|
+
raise ConfigError(msg)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def check_ownership(path: Path, trusted_uids: Collection[int] = (_ROOT_UID,)) -> None:
|
|
86
|
+
"""Check that path, its symlink target, and every ancestor directory can only be changed by a trusted user.
|
|
87
|
+
|
|
88
|
+
Each must be owned by a trusted user (root) and not writable by group or others. A world-writable ancestor is
|
|
89
|
+
accepted only if it has the sticky bit (e.g. /tmp), since others can't then replace what's inside it.
|
|
90
|
+
"""
|
|
91
|
+
try:
|
|
92
|
+
real = path.resolve(strict=True)
|
|
93
|
+
except OSError as e:
|
|
94
|
+
msg = f"{path}: {e.strerror}"
|
|
95
|
+
raise ConfigError(msg) from e
|
|
96
|
+
for depth, current in enumerate([real, *real.parents]):
|
|
97
|
+
mode = current.stat().st_mode
|
|
98
|
+
if current.stat().st_uid not in trusted_uids:
|
|
99
|
+
msg = f"{current}: must be owned by root"
|
|
100
|
+
raise ConfigError(msg)
|
|
101
|
+
sticky_ancestor = depth > 0 and stat.S_ISDIR(mode) and mode & stat.S_ISVTX
|
|
102
|
+
if mode & (stat.S_IWGRP | stat.S_IWOTH) and not sticky_ancestor:
|
|
103
|
+
msg = f"{current}: must not be writable by group or others"
|
|
104
|
+
raise ConfigError(msg)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_policy(path: Path, *, check_owners: bool = True, trusted_uids: Collection[int] = (_ROOT_UID,)) -> Policy:
|
|
108
|
+
"""Load, merge, and validate the policy at path; raise ConfigError on any problem."""
|
|
109
|
+
files = policy_files(path)
|
|
110
|
+
if check_owners:
|
|
111
|
+
check_ownership(path, trusted_uids)
|
|
112
|
+
for file in files:
|
|
113
|
+
check_ownership(file, trusted_uids)
|
|
114
|
+
|
|
115
|
+
entries: dict[str, _Entry] = {}
|
|
116
|
+
for file in files:
|
|
117
|
+
for key, table in _read(file).items():
|
|
118
|
+
_merge(entries, key, table, file.name)
|
|
119
|
+
|
|
120
|
+
commands = [_build(entries, key) for key in sorted(entries) if " " not in key]
|
|
121
|
+
for key, entry in entries.items():
|
|
122
|
+
parent = key.rpartition(" ")[0]
|
|
123
|
+
if parent and parent not in entries:
|
|
124
|
+
msg = f"{_where(entry, key)}: parent command {parent!r} is not defined"
|
|
125
|
+
raise ConfigError(msg)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
root = CommandRoot(commands)
|
|
129
|
+
except SpecError as e:
|
|
130
|
+
source = e.command.source if e.command is not None else None
|
|
131
|
+
msg = f"{source}: {e}" if source else str(e)
|
|
132
|
+
raise ConfigError(msg) from e
|
|
133
|
+
return Policy(files=files, commands=commands, root=root)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _read(file: Path) -> dict[str, Any]:
|
|
137
|
+
"""Read one policy file and return its commands table."""
|
|
138
|
+
try:
|
|
139
|
+
with file.open("rb") as f:
|
|
140
|
+
data = tomllib.load(f)
|
|
141
|
+
except (OSError, tomllib.TOMLDecodeError) as e:
|
|
142
|
+
msg = f"{file.name}: {e}"
|
|
143
|
+
raise ConfigError(msg) from e
|
|
144
|
+
_check_keys(data, _TOP_LEVEL_KEYS, file.name)
|
|
145
|
+
commands = data.get("commands", {})
|
|
146
|
+
if not isinstance(commands, dict):
|
|
147
|
+
msg = f"{file.name}: 'commands' must be a table"
|
|
148
|
+
raise ConfigError(msg)
|
|
149
|
+
return commands
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _merge(entries: dict[str, _Entry], key: str, table: Any, source: str) -> None: # noqa: ANN401
|
|
153
|
+
"""Merge one file's definition of a command path into entries."""
|
|
154
|
+
where = f"{source}: commands.{key!r}"
|
|
155
|
+
names = key.split(" ")
|
|
156
|
+
if not all(_NAME.fullmatch(n) for n in names):
|
|
157
|
+
msg = f"{where}: a command path is names separated by single spaces; names use letters, digits and _.-"
|
|
158
|
+
raise ConfigError(msg)
|
|
159
|
+
if not isinstance(table, dict):
|
|
160
|
+
msg = f"{where}: must be a table"
|
|
161
|
+
raise ConfigError(msg)
|
|
162
|
+
definition = {k: v for k, v in table.items() if k not in _EXAMPLE_KEYS}
|
|
163
|
+
entry = entries.get(key)
|
|
164
|
+
if entry is None:
|
|
165
|
+
entry = entries[key] = _Entry(definition=definition, sources=[source])
|
|
166
|
+
elif entry.definition != definition:
|
|
167
|
+
msg = (
|
|
168
|
+
f"commands.{key!r} is defined differently in {entry.sources[0]} and {source}; "
|
|
169
|
+
"a command shared by several files must be identical in each"
|
|
170
|
+
)
|
|
171
|
+
raise ConfigError(msg)
|
|
172
|
+
else:
|
|
173
|
+
entry.sources.append(source)
|
|
174
|
+
entry.accept.extend(_list(table, "accept", where))
|
|
175
|
+
entry.reject.extend(_list(table, "reject", where))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _build(entries: dict[str, _Entry], key: str) -> Command:
|
|
179
|
+
"""Build the Command for a path, and its subcommands, from merged entries."""
|
|
180
|
+
entry = entries[key]
|
|
181
|
+
where = _where(entry, key)
|
|
182
|
+
table = entry.definition
|
|
183
|
+
_check_keys(table, _COMMAND_KEYS, where)
|
|
184
|
+
|
|
185
|
+
args: list[Option | Flag | Positional] = []
|
|
186
|
+
for name, arg in _table(table, "options", where).items():
|
|
187
|
+
arg_where = f"{where}: options.{name!r}"
|
|
188
|
+
required = _bool(_as_table(arg, arg_where), "required", arg_where, default=False)
|
|
189
|
+
help_, validator = _validated_arg({k: v for k, v in arg.items() if k != "required"}, arg_where)
|
|
190
|
+
args.append(Option(name=name, help=help_, validator=validator, required=required))
|
|
191
|
+
for name, arg in _table(table, "flags", where).items():
|
|
192
|
+
arg_where = f"{where}: flags.{name!r}"
|
|
193
|
+
_check_keys(_as_table(arg, arg_where), frozenset({"help", "required"}), arg_where)
|
|
194
|
+
required = _bool(arg, "required", arg_where, default=False)
|
|
195
|
+
args.append(Flag(name=name, help=_str(arg, "help", arg_where, required=True), required=required))
|
|
196
|
+
for index, arg in enumerate(_list(table, "positionals", where)):
|
|
197
|
+
arg_where = f"{where}: positionals[{index}]"
|
|
198
|
+
name = _str(_as_table(arg, arg_where), "name", arg_where, required=True)
|
|
199
|
+
help_, validator = _validated_arg({k: v for k, v in arg.items() if k != "name"}, arg_where)
|
|
200
|
+
args.append(Positional(name=name, help=help_, validator=validator))
|
|
201
|
+
|
|
202
|
+
prefix = f"{key} "
|
|
203
|
+
children = sorted(k for k in entries if k.startswith(prefix) and " " not in k.removeprefix(prefix))
|
|
204
|
+
return Command(
|
|
205
|
+
name=key.rpartition(" ")[2],
|
|
206
|
+
help=_str(table, "help", where, required=True),
|
|
207
|
+
run=_str(table, "run", where),
|
|
208
|
+
args=args,
|
|
209
|
+
subcommands=[_build(entries, child) for child in children],
|
|
210
|
+
end_of_options=_bool(table, "end_of_options", where, default=True),
|
|
211
|
+
accept=[_accept_example(ex, f"{where}: accept") for ex in entry.accept],
|
|
212
|
+
reject=[_example_str(ex, f"{where}: reject") for ex in entry.reject],
|
|
213
|
+
source=", ".join(entry.sources),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _validated_arg(arg: Any, where: str) -> tuple[str, Validator]: # noqa: ANN401
|
|
218
|
+
"""Return (help, validator) for an option or positional table."""
|
|
219
|
+
table = _as_table(arg, where)
|
|
220
|
+
_check_keys(table, frozenset({"help", "description"}) | _VALIDATOR_KEYS, where)
|
|
221
|
+
help_ = _str(table, "help", where, required=True)
|
|
222
|
+
kinds = sorted(_VALIDATOR_KEYS & table.keys())
|
|
223
|
+
if len(kinds) != 1:
|
|
224
|
+
msg = f"{where}: needs exactly one of {', '.join(sorted(_VALIDATOR_KEYS))}"
|
|
225
|
+
raise ConfigError(msg)
|
|
226
|
+
kind = kinds[0]
|
|
227
|
+
if "description" in table and kind != "pattern":
|
|
228
|
+
msg = f"{where}: 'description' is only used with 'pattern'"
|
|
229
|
+
raise ConfigError(msg)
|
|
230
|
+
|
|
231
|
+
if kind == "validator":
|
|
232
|
+
name = _str(table, "validator", where, required=True)
|
|
233
|
+
if name not in NAMED:
|
|
234
|
+
msg = f"{where}: unknown validator {name!r} (choose from {', '.join(sorted(NAMED))})"
|
|
235
|
+
raise ConfigError(msg)
|
|
236
|
+
return help_, NAMED[name]
|
|
237
|
+
if kind == "pattern":
|
|
238
|
+
regex = _str(table, "pattern", where, required=True)
|
|
239
|
+
try:
|
|
240
|
+
re.compile(regex)
|
|
241
|
+
except re.error as e:
|
|
242
|
+
msg = f"{where}: invalid pattern: {e}"
|
|
243
|
+
raise ConfigError(msg) from e
|
|
244
|
+
return help_, Pattern(regex, _str(table, "description", where, required=True))
|
|
245
|
+
fields = _str_list(table, "go_template", where)
|
|
246
|
+
if not fields or not all(re.fullmatch(r"[A-Za-z][A-Za-z0-9]*", f) for f in fields):
|
|
247
|
+
msg = f"{where}: go_template must be a non-empty list of field names"
|
|
248
|
+
raise ConfigError(msg)
|
|
249
|
+
template = GoTemplateFormat(fields)
|
|
250
|
+
return f"{help_} Fields: {', '.join(template.fields)}.", template
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _accept_example(example: Any, where: str) -> str | tuple[str, str]: # noqa: ANN401
|
|
254
|
+
"""Return an accept example: a command line, or a (command line, expected argv) pair."""
|
|
255
|
+
if isinstance(example, dict):
|
|
256
|
+
_check_keys(example, frozenset({"command", "argv"}), where)
|
|
257
|
+
return (_str(example, "command", where, required=True), _str(example, "argv", where, required=True))
|
|
258
|
+
return _example_str(example, where)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _example_str(example: Any, where: str) -> str: # noqa: ANN401
|
|
262
|
+
"""Return an example command line."""
|
|
263
|
+
if not isinstance(example, str):
|
|
264
|
+
msg = f"{where}: examples must be strings (or, for accept, {{ command = ..., argv = ... }} tables)"
|
|
265
|
+
raise ConfigError(msg)
|
|
266
|
+
return example
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _where(entry: _Entry, key: str) -> str:
|
|
270
|
+
"""Describe where a command is defined, for error messages."""
|
|
271
|
+
return f"{', '.join(entry.sources)}: commands.{key!r}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _check_keys(table: dict[str, Any], allowed: frozenset[str], where: str) -> None:
|
|
275
|
+
"""Reject unknown keys, so a typo can't be silently ignored."""
|
|
276
|
+
if unknown := sorted(table.keys() - allowed):
|
|
277
|
+
msg = f"{where}: unknown key(s) {', '.join(map(repr, unknown))} (allowed: {', '.join(sorted(allowed))})"
|
|
278
|
+
raise ConfigError(msg)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _as_table(value: Any, where: str) -> dict[str, Any]: # noqa: ANN401
|
|
282
|
+
"""Return value if it's a table."""
|
|
283
|
+
if not isinstance(value, dict):
|
|
284
|
+
msg = f"{where}: must be a table"
|
|
285
|
+
raise ConfigError(msg)
|
|
286
|
+
return value
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _table(table: dict[str, Any], key: str, where: str) -> dict[str, Any]:
|
|
290
|
+
"""Return an optional sub-table."""
|
|
291
|
+
return _as_table(table.get(key, {}), f"{where}: {key}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _list(table: dict[str, Any], key: str, where: str) -> list[Any]:
|
|
295
|
+
"""Return an optional list."""
|
|
296
|
+
value = table.get(key, [])
|
|
297
|
+
if not isinstance(value, list):
|
|
298
|
+
msg = f"{where}: {key} must be a list"
|
|
299
|
+
raise ConfigError(msg)
|
|
300
|
+
return value
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _str_list(table: dict[str, Any], key: str, where: str) -> list[str]:
|
|
304
|
+
"""Return an optional list of strings."""
|
|
305
|
+
value = _list(table, key, where)
|
|
306
|
+
if not all(isinstance(v, str) for v in value):
|
|
307
|
+
msg = f"{where}: {key} must be a list of strings"
|
|
308
|
+
raise ConfigError(msg)
|
|
309
|
+
return value
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _str(table: dict[str, Any], key: str, where: str, *, required: bool = False) -> Any: # noqa: ANN401
|
|
313
|
+
"""Return an optional (or required, non-empty) string."""
|
|
314
|
+
value = table.get(key)
|
|
315
|
+
if value is None and not required:
|
|
316
|
+
return None
|
|
317
|
+
if not isinstance(value, str) or (required and not value.strip()):
|
|
318
|
+
msg = f"{where}: {key} must be a{' non-empty' if required else ''} string"
|
|
319
|
+
raise ConfigError(msg)
|
|
320
|
+
return value
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _bool(table: dict[str, Any], key: str, where: str, *, default: bool) -> bool:
|
|
324
|
+
"""Return an optional boolean."""
|
|
325
|
+
value = table.get(key, default)
|
|
326
|
+
if not isinstance(value, bool):
|
|
327
|
+
msg = f"{where}: {key} must be true or false"
|
|
328
|
+
raise ConfigError(msg)
|
|
329
|
+
return value
|
sshfc/core/executor.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Run an accepted command's argv."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import pwd
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TYPE_CHECKING, Final
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
_PATH: Final = "/usr/bin:/bin"
|
|
18
|
+
|
|
19
|
+
# Variables passed through from sshfc's own environment, if set. Everything else is dropped.
|
|
20
|
+
_PASSTHROUGH: Final = (
|
|
21
|
+
"LANG",
|
|
22
|
+
"LC_ALL",
|
|
23
|
+
"TERM",
|
|
24
|
+
"XDG_RUNTIME_DIR", # Needed by rootless podman.
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Exit statuses for failures to start the program, following shell conventions.
|
|
28
|
+
_EXIT_CANNOT_EXECUTE: Final = 126
|
|
29
|
+
_EXIT_NOT_FOUND: Final = 127
|
|
30
|
+
_EXIT_SIGNAL_BASE: Final = 128
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def minimal_env(environ: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
34
|
+
"""Return the environment to run commands with.
|
|
35
|
+
|
|
36
|
+
PATH is fixed, and HOME/USER/LOGNAME come from the passwd entry rather than the inherited environment.
|
|
37
|
+
"""
|
|
38
|
+
environ = os.environ if environ is None else environ
|
|
39
|
+
user = pwd.getpwuid(os.getuid())
|
|
40
|
+
env = {"PATH": _PATH, "HOME": user.pw_dir, "USER": user.pw_name, "LOGNAME": user.pw_name}
|
|
41
|
+
env.update({name: environ[name] for name in _PASSTHROUGH if name in environ})
|
|
42
|
+
return env
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def execute(argv: Sequence[str]) -> int:
|
|
46
|
+
"""Run argv without a shell, with stdin/stdout/stderr passed through; return an exit status for sshfc."""
|
|
47
|
+
try:
|
|
48
|
+
# argv was rebuilt from a validated parse (never the raw command string), and no shell is involved.
|
|
49
|
+
result = subprocess.run(argv, shell=False, env=minimal_env(), check=False) # noqa: S603
|
|
50
|
+
except FileNotFoundError:
|
|
51
|
+
print(f"SSH command failed: {argv[0]}: not found", file=sys.stderr)
|
|
52
|
+
return _EXIT_NOT_FOUND
|
|
53
|
+
except PermissionError:
|
|
54
|
+
print(f"SSH command failed: {argv[0]}: permission denied", file=sys.stderr)
|
|
55
|
+
return _EXIT_CANNOT_EXECUTE
|
|
56
|
+
if result.returncode < 0: # Killed by a signal.
|
|
57
|
+
return _EXIT_SIGNAL_BASE - result.returncode
|
|
58
|
+
return result.returncode
|
sshfc/core/parser.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Copyright (C) 2026 Stephen T. Jibson.
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Strict parser built from command specs; renders the argv to run for an accepted command."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import shlex
|
|
10
|
+
from typing import TYPE_CHECKING, Any, Final, NoReturn
|
|
11
|
+
|
|
12
|
+
from sshfc.core.spec import Command, Flag, Option, Positional
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
# _SubParsersAction is only generic in the type stubs, not at runtime.
|
|
18
|
+
_SubParsersActionBase = argparse._SubParsersAction[argparse.ArgumentParser] # noqa: SLF001
|
|
19
|
+
else:
|
|
20
|
+
_SubParsersActionBase = argparse._SubParsersAction # noqa: SLF001
|
|
21
|
+
|
|
22
|
+
# Namespace keys used internally; prefixed so they can't collide with anything argparse derives.
|
|
23
|
+
_LEAF: Final = "_sshfc_leaf"
|
|
24
|
+
_ARG: Final = "_sshfc_arg_{index}"
|
|
25
|
+
_SUBCOMMAND: Final = "_sshfc_subcommand:{prog}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidCommandError(Exception):
|
|
29
|
+
"""Raised when a command is invalid."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SpecError(Exception):
|
|
33
|
+
"""Raised when a command spec is malformed (a maintainer error, not an SSH user error)."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, command: Command | None = None) -> None:
|
|
36
|
+
"""Initialize the error with the command whose spec is malformed, if any."""
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
self.command = command
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ParseError(Exception):
|
|
42
|
+
"""Raised by _StrictArgumentParser instead of exiting."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, prog: str, message: str) -> None:
|
|
45
|
+
"""Initialize the error with the prog (subcommand path) of the parser that raised it."""
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.prog = prog
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _SubcommandsAction(_SubParsersActionBase):
|
|
51
|
+
"""Subparsers action that gives each subcommand a clean prog and uses its help as its description."""
|
|
52
|
+
|
|
53
|
+
def add_parser(self, name: str, **kwargs: Any) -> argparse.ArgumentParser: # noqa: ANN401
|
|
54
|
+
"""Add a subcommand parser."""
|
|
55
|
+
# argparse joins the prefix and name with a space, which leaves a leading space when the root prog is "".
|
|
56
|
+
kwargs.setdefault("prog", f"{self._prog_prefix} {name}".strip())
|
|
57
|
+
if "help" in kwargs:
|
|
58
|
+
kwargs.setdefault("description", kwargs["help"])
|
|
59
|
+
return super().add_parser(name, **kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _StrictArgumentParser(argparse.ArgumentParser):
|
|
63
|
+
"""ArgumentParser that never exits on error and never matches abbreviated options.
|
|
64
|
+
|
|
65
|
+
Subparsers are created with the parent's class, so every parser in the tree inherits this behavior.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401
|
|
69
|
+
"""Initialize the parser."""
|
|
70
|
+
kwargs.setdefault("allow_abbrev", False)
|
|
71
|
+
super().__init__(*args, **kwargs)
|
|
72
|
+
self.register("action", "parsers", _SubcommandsAction)
|
|
73
|
+
|
|
74
|
+
def parse_known_args( # type: ignore[override]
|
|
75
|
+
self, args: Sequence[str] | None = None, namespace: argparse.Namespace | None = None
|
|
76
|
+
) -> tuple[argparse.Namespace, list[str]]:
|
|
77
|
+
"""Parse args, rejecting unrecognized args and requiring a subcommand wherever subcommands are defined.
|
|
78
|
+
|
|
79
|
+
Both checks happen here, in the parser that owns the args, so the error names the right subcommand path.
|
|
80
|
+
argparse's own ``required=True`` check only names the metavar; this lists the valid choices too.
|
|
81
|
+
"""
|
|
82
|
+
parsed, extras = super().parse_known_args(args, namespace)
|
|
83
|
+
if extras:
|
|
84
|
+
self.error(f"unrecognized arguments: {' '.join(extras)}")
|
|
85
|
+
for action in self._actions:
|
|
86
|
+
if isinstance(action, argparse._SubParsersAction) and getattr(parsed, action.dest, None) is None: # noqa: SLF001
|
|
87
|
+
choices = ", ".join(action.choices)
|
|
88
|
+
self.error(f"the following arguments are required: {action.metavar} (choose from {choices})")
|
|
89
|
+
return parsed, extras
|
|
90
|
+
|
|
91
|
+
def error(self, message: str) -> NoReturn:
|
|
92
|
+
"""Raise instead of printing usage and exiting."""
|
|
93
|
+
raise _ParseError(self.prog, message)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CommandRoot:
|
|
97
|
+
"""Parser for the allowed command set."""
|
|
98
|
+
|
|
99
|
+
_REJECTED_MSG: Final = "SSH command rejected: {name} -- {error}"
|
|
100
|
+
_REJECTED_NO_NAME_MSG: Final = "SSH command rejected: {error}"
|
|
101
|
+
|
|
102
|
+
def __init__(self, commands: Sequence[Command]) -> None:
|
|
103
|
+
"""Build the parser from command specs; raise SpecError if a spec is malformed."""
|
|
104
|
+
self._parser = _StrictArgumentParser(prog="")
|
|
105
|
+
group = self._add_subcommands(self._parser, metavar="command", title="available commands")
|
|
106
|
+
self._check_unique([c.name for c in commands], "top-level commands", None)
|
|
107
|
+
for command in commands:
|
|
108
|
+
self._add_command(group, command, path=(), run=None)
|
|
109
|
+
|
|
110
|
+
def parse(self, cmd_str: str) -> list[str]:
|
|
111
|
+
"""Validate a command line and return the argv to run; raise InvalidCommandError if it isn't allowed."""
|
|
112
|
+
argv, _ = self.parse_command(cmd_str)
|
|
113
|
+
return argv
|
|
114
|
+
|
|
115
|
+
def parse_command(self, cmd_str: str) -> tuple[list[str], Command]:
|
|
116
|
+
"""Like parse(), but also return the matched leaf command (e.g. for its source)."""
|
|
117
|
+
try:
|
|
118
|
+
args = shlex.split(cmd_str)
|
|
119
|
+
except ValueError as e:
|
|
120
|
+
raise InvalidCommandError(self._REJECTED_NO_NAME_MSG.format(error=e)) from e
|
|
121
|
+
try:
|
|
122
|
+
namespace = self._parser.parse_args(args)
|
|
123
|
+
except _ParseError as e:
|
|
124
|
+
msg = (
|
|
125
|
+
self._REJECTED_MSG.format(name=e.prog, error=e)
|
|
126
|
+
if e.prog
|
|
127
|
+
else self._REJECTED_NO_NAME_MSG.format(error=e)
|
|
128
|
+
)
|
|
129
|
+
raise InvalidCommandError(msg) from e
|
|
130
|
+
path: tuple[Command, ...] = getattr(namespace, _LEAF)
|
|
131
|
+
return self._render(namespace), path[-1]
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _add_subcommands(
|
|
135
|
+
parser: argparse.ArgumentParser, metavar: str = "subcommand", title: str = "available subcommands"
|
|
136
|
+
) -> argparse._SubParsersAction[argparse.ArgumentParser]:
|
|
137
|
+
"""Add a group of subcommands to a parser.
|
|
138
|
+
|
|
139
|
+
The subcommand is required, but that is enforced by _StrictArgumentParser.parse_known_args() rather than
|
|
140
|
+
argparse's ``required=True`` so the error message can list the valid choices.
|
|
141
|
+
"""
|
|
142
|
+
dest = _SUBCOMMAND.format(prog=parser.prog)
|
|
143
|
+
return parser.add_subparsers(title=title, dest=dest, metavar=metavar)
|
|
144
|
+
|
|
145
|
+
def _add_command(
|
|
146
|
+
self,
|
|
147
|
+
group: argparse._SubParsersAction[argparse.ArgumentParser],
|
|
148
|
+
command: Command,
|
|
149
|
+
path: tuple[Command, ...],
|
|
150
|
+
run: str | None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Add a command (and, recursively, its subcommands) to a subcommand group."""
|
|
153
|
+
path = (*path, command)
|
|
154
|
+
where = " ".join(c.name for c in path)
|
|
155
|
+
if command.run is not None:
|
|
156
|
+
if not command.run.startswith("/"):
|
|
157
|
+
msg = f"{where}: run must be an absolute path, got {command.run!r}"
|
|
158
|
+
raise SpecError(msg, command)
|
|
159
|
+
run = command.run
|
|
160
|
+
|
|
161
|
+
parser = group.add_parser(command.name, help=command.help)
|
|
162
|
+
|
|
163
|
+
if command.subcommands:
|
|
164
|
+
if command.args:
|
|
165
|
+
msg = f"{where}: only leaf commands (without subcommands) may have args"
|
|
166
|
+
raise SpecError(msg, command)
|
|
167
|
+
self._check_unique([c.name for c in command.subcommands], f"{where} subcommands", command)
|
|
168
|
+
sub_group = self._add_subcommands(parser)
|
|
169
|
+
for subcommand in command.subcommands:
|
|
170
|
+
self._add_command(sub_group, subcommand, path, run)
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
if run is None:
|
|
174
|
+
msg = f"{where}: no run path (set run= on this command or an ancestor)"
|
|
175
|
+
raise SpecError(msg, command)
|
|
176
|
+
self._add_args(parser, command, where)
|
|
177
|
+
parser.set_defaults(**{_LEAF: path})
|
|
178
|
+
|
|
179
|
+
def _add_args(self, parser: argparse.ArgumentParser, command: Command, where: str) -> None:
|
|
180
|
+
"""Add a leaf command's options, flags and positionals to its parser."""
|
|
181
|
+
self._check_unique([a.name for a in command.args], f"{where} args", command)
|
|
182
|
+
for index, arg in enumerate(command.args):
|
|
183
|
+
dest = _ARG.format(index=index)
|
|
184
|
+
match arg:
|
|
185
|
+
case Option():
|
|
186
|
+
self._check_option_name(arg.name, where, command)
|
|
187
|
+
metavar = arg.name.lstrip("-").upper()
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
arg.name, dest=dest, type=arg.validator, metavar=metavar, required=arg.required, help=arg.help
|
|
190
|
+
)
|
|
191
|
+
case Flag():
|
|
192
|
+
self._check_option_name(arg.name, where, command)
|
|
193
|
+
parser.add_argument(arg.name, dest=dest, action="store_true", required=arg.required, help=arg.help)
|
|
194
|
+
case Positional():
|
|
195
|
+
if arg.name.startswith("-"):
|
|
196
|
+
msg = f"{where}: positional names must not start with '-', got {arg.name!r}"
|
|
197
|
+
raise SpecError(msg, command)
|
|
198
|
+
parser.add_argument(dest, type=arg.validator, metavar=arg.name, help=arg.help)
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def _check_option_name(name: str, where: str, command: Command) -> None:
|
|
202
|
+
"""Check that an option or flag name looks like one."""
|
|
203
|
+
if not name.startswith("-") or name in {"-", "--", "-h", "--help"}:
|
|
204
|
+
msg = f"{where}: invalid option name {name!r}"
|
|
205
|
+
raise SpecError(msg, command)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _check_unique(names: Sequence[str], where: str, command: Command | None) -> None:
|
|
209
|
+
"""Check that names are unique."""
|
|
210
|
+
if duplicates := sorted({n for n in names if names.count(n) > 1}):
|
|
211
|
+
msg = f"{where}: duplicate names {duplicates}"
|
|
212
|
+
raise SpecError(msg, command)
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _render(namespace: argparse.Namespace) -> list[str]:
|
|
216
|
+
"""Build the argv to run from the parsed namespace -- never from the original command string."""
|
|
217
|
+
path: tuple[Command, ...] = getattr(namespace, _LEAF)
|
|
218
|
+
argv: list[str] = []
|
|
219
|
+
for command in path:
|
|
220
|
+
if command.run is not None:
|
|
221
|
+
argv = [command.run]
|
|
222
|
+
else:
|
|
223
|
+
argv.append(command.name)
|
|
224
|
+
|
|
225
|
+
leaf = path[-1]
|
|
226
|
+
positionals: list[str] = []
|
|
227
|
+
for index, arg in enumerate(leaf.args):
|
|
228
|
+
value = getattr(namespace, _ARG.format(index=index))
|
|
229
|
+
match arg:
|
|
230
|
+
case Option() if value is not None:
|
|
231
|
+
argv.extend([arg.name, value])
|
|
232
|
+
case Flag() if value:
|
|
233
|
+
argv.append(arg.name)
|
|
234
|
+
case Positional():
|
|
235
|
+
positionals.append(value)
|
|
236
|
+
if positionals:
|
|
237
|
+
if leaf.end_of_options:
|
|
238
|
+
argv.append("--")
|
|
239
|
+
argv.extend(positionals)
|
|
240
|
+
return argv
|