nsjail-python 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.
- nsjail/__init__.py +21 -0
- nsjail/_field_meta.py +177 -0
- nsjail/_proto/__init__.py +57 -0
- nsjail/_proto/config_pb2.py +50 -0
- nsjail/builder.py +151 -0
- nsjail/config.py +168 -0
- nsjail/enums.py +27 -0
- nsjail/exceptions.py +29 -0
- nsjail/presets.py +74 -0
- nsjail/runner.py +274 -0
- nsjail/serializers/__init__.py +26 -0
- nsjail/serializers/cli.py +69 -0
- nsjail/serializers/protobuf.py +22 -0
- nsjail/serializers/textproto.py +91 -0
- nsjail_python-0.1.0.dist-info/METADATA +141 -0
- nsjail_python-0.1.0.dist-info/RECORD +18 -0
- nsjail_python-0.1.0.dist-info/WHEEL +4 -0
- nsjail_python-0.1.0.dist-info/licenses/LICENSE +21 -0
nsjail/enums.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# GENERATED from nsjail config.proto — DO NOT EDIT
|
|
2
|
+
# Re-run: python -m _codegen.generate
|
|
3
|
+
|
|
4
|
+
from enum import IntEnum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Mode(IntEnum):
|
|
8
|
+
LISTEN = 0
|
|
9
|
+
ONCE = 1
|
|
10
|
+
RERUN = 2
|
|
11
|
+
EXECVE = 3
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LogLevel(IntEnum):
|
|
15
|
+
DEBUG = 0
|
|
16
|
+
INFO = 1
|
|
17
|
+
WARNING = 2
|
|
18
|
+
ERROR = 3
|
|
19
|
+
FATAL = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RLimitType(IntEnum):
|
|
23
|
+
VALUE = 0
|
|
24
|
+
SOFT = 1
|
|
25
|
+
HARD = 2
|
|
26
|
+
INF = 3
|
|
27
|
+
|
nsjail/exceptions.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Exception types for nsjail-python."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NsjailError(Exception):
|
|
5
|
+
"""Base exception for all nsjail-python errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedCLIField(NsjailError):
|
|
9
|
+
"""Raised when a config field has no CLI flag equivalent."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, field_name: str) -> None:
|
|
12
|
+
self.field_name = field_name
|
|
13
|
+
super().__init__(
|
|
14
|
+
f"Config field {field_name!r} has no CLI flag equivalent. "
|
|
15
|
+
f"Use textproto rendering instead, or pass on_unsupported='skip'."
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NsjailNotFound(NsjailError):
|
|
20
|
+
"""Raised when the nsjail binary cannot be found."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__(
|
|
24
|
+
"nsjail binary not found. Install it via:\n"
|
|
25
|
+
" pip install nsjail-python # includes pre-built binary\n"
|
|
26
|
+
" pip install nsjail-python[build] # build from source\n"
|
|
27
|
+
" apt-get install nsjail # system package\n"
|
|
28
|
+
"Or specify the path: Runner(nsjail_path='/path/to/nsjail')"
|
|
29
|
+
)
|
nsjail/presets.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Opinionated preset configurations and composable modifiers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nsjail.config import Exe, MountPt, NsJailConfig
|
|
6
|
+
from nsjail.enums import Mode
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def apply_readonly_root(
|
|
10
|
+
cfg: NsJailConfig,
|
|
11
|
+
*,
|
|
12
|
+
writable: list[str] | None = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
cfg.mount.append(MountPt(src="/", dst="/", is_bind=True, rw=False))
|
|
15
|
+
|
|
16
|
+
for path in writable or []:
|
|
17
|
+
if path == "/tmp":
|
|
18
|
+
cfg.mount.append(
|
|
19
|
+
MountPt(dst="/tmp", fstype="tmpfs", rw=True, is_dir=True)
|
|
20
|
+
)
|
|
21
|
+
else:
|
|
22
|
+
cfg.mount.append(
|
|
23
|
+
MountPt(src=path, dst=path, is_bind=True, rw=True)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def apply_cgroup_limits(
|
|
28
|
+
cfg: NsJailConfig,
|
|
29
|
+
*,
|
|
30
|
+
memory_mb: int | None = None,
|
|
31
|
+
cpu_ms_per_sec: int | None = None,
|
|
32
|
+
pids_max: int | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
if memory_mb is not None:
|
|
35
|
+
cfg.cgroup_mem_max = memory_mb * 1024 * 1024
|
|
36
|
+
if cpu_ms_per_sec is not None:
|
|
37
|
+
cfg.cgroup_cpu_ms_per_sec = cpu_ms_per_sec
|
|
38
|
+
if pids_max is not None:
|
|
39
|
+
cfg.cgroup_pids_max = pids_max
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def apply_seccomp_log(cfg: NsJailConfig) -> None:
|
|
43
|
+
cfg.seccomp_log = True
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def sandbox(
|
|
47
|
+
*,
|
|
48
|
+
command: list[str],
|
|
49
|
+
cwd: str = "/",
|
|
50
|
+
timeout_sec: int = 600,
|
|
51
|
+
memory_mb: int | None = None,
|
|
52
|
+
cpu_ms_per_sec: int | None = None,
|
|
53
|
+
pids_max: int | None = None,
|
|
54
|
+
network: bool = False,
|
|
55
|
+
writable_dirs: list[str] | None = None,
|
|
56
|
+
) -> NsJailConfig:
|
|
57
|
+
cfg = NsJailConfig(
|
|
58
|
+
mode=Mode.ONCE,
|
|
59
|
+
cwd=cwd,
|
|
60
|
+
time_limit=timeout_sec,
|
|
61
|
+
clone_newnet=not network,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
cfg.exec_bin = Exe(path=command[0], arg=command[1:])
|
|
65
|
+
|
|
66
|
+
apply_readonly_root(cfg, writable=writable_dirs)
|
|
67
|
+
apply_cgroup_limits(
|
|
68
|
+
cfg,
|
|
69
|
+
memory_mb=memory_mb,
|
|
70
|
+
cpu_ms_per_sec=cpu_ms_per_sec,
|
|
71
|
+
pids_max=pids_max,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return cfg
|
nsjail/runner.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Runner for executing nsjail sandboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import copy
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass, fields as dc_fields
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from nsjail.config import NsJailConfig
|
|
15
|
+
from nsjail.exceptions import NsjailNotFound
|
|
16
|
+
from nsjail.serializers import to_file
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _try_companion_binary() -> Path | None:
|
|
20
|
+
"""Try to find the nsjail binary from companion packages."""
|
|
21
|
+
for module_name in ("nsjail_bin", "nsjail_bin_build"):
|
|
22
|
+
try:
|
|
23
|
+
mod = __import__(module_name)
|
|
24
|
+
return mod.binary_path()
|
|
25
|
+
except (ImportError, AttributeError, FileNotFoundError):
|
|
26
|
+
continue
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_nsjail_path(explicit_path: str | None) -> Path:
|
|
31
|
+
"""Resolve the nsjail binary path.
|
|
32
|
+
|
|
33
|
+
Precedence: explicit path > system PATH > companion package > error.
|
|
34
|
+
"""
|
|
35
|
+
if explicit_path is not None:
|
|
36
|
+
return Path(explicit_path)
|
|
37
|
+
|
|
38
|
+
system = shutil.which("nsjail")
|
|
39
|
+
if system is not None:
|
|
40
|
+
return Path(system)
|
|
41
|
+
|
|
42
|
+
companion = _try_companion_binary()
|
|
43
|
+
if companion is not None:
|
|
44
|
+
return companion
|
|
45
|
+
|
|
46
|
+
raise NsjailNotFound()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def merge_configs(
|
|
50
|
+
base: NsJailConfig,
|
|
51
|
+
overrides: NsJailConfig,
|
|
52
|
+
*,
|
|
53
|
+
override_fields: set[str],
|
|
54
|
+
extra_args: list[str] | None = None,
|
|
55
|
+
) -> NsJailConfig:
|
|
56
|
+
"""Merge an override config into a base config.
|
|
57
|
+
|
|
58
|
+
Scalars in override_fields replace the base value.
|
|
59
|
+
Lists in override_fields are appended.
|
|
60
|
+
extra_args are appended to exec_bin.arg.
|
|
61
|
+
"""
|
|
62
|
+
merged = copy.deepcopy(base)
|
|
63
|
+
|
|
64
|
+
for f in dc_fields(NsJailConfig):
|
|
65
|
+
if f.name not in override_fields:
|
|
66
|
+
continue
|
|
67
|
+
|
|
68
|
+
override_val = getattr(overrides, f.name)
|
|
69
|
+
base_val = getattr(merged, f.name)
|
|
70
|
+
|
|
71
|
+
if isinstance(base_val, list):
|
|
72
|
+
base_val.extend(override_val)
|
|
73
|
+
else:
|
|
74
|
+
setattr(merged, f.name, override_val)
|
|
75
|
+
|
|
76
|
+
if extra_args and merged.exec_bin is not None:
|
|
77
|
+
merged.exec_bin.arg.extend(extra_args)
|
|
78
|
+
|
|
79
|
+
return merged
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class NsJailResult:
|
|
84
|
+
"""Result of running nsjail."""
|
|
85
|
+
|
|
86
|
+
returncode: int
|
|
87
|
+
stdout: bytes
|
|
88
|
+
stderr: bytes
|
|
89
|
+
config_path: Path | None
|
|
90
|
+
nsjail_args: list[str]
|
|
91
|
+
timed_out: bool
|
|
92
|
+
oom_killed: bool
|
|
93
|
+
signaled: bool
|
|
94
|
+
inner_returncode: int | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Runner:
|
|
98
|
+
"""Configurable nsjail executor with optional baked-in config."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
nsjail_path: str | None = None,
|
|
104
|
+
base_config: NsJailConfig | None = None,
|
|
105
|
+
render_mode: str = "textproto",
|
|
106
|
+
capture_output: bool = True,
|
|
107
|
+
keep_config: bool = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
self._nsjail_path = nsjail_path
|
|
110
|
+
self._base_config = copy.deepcopy(base_config) if base_config else NsJailConfig()
|
|
111
|
+
self._render_mode = render_mode
|
|
112
|
+
self._capture_output = capture_output
|
|
113
|
+
self._keep_config = keep_config
|
|
114
|
+
|
|
115
|
+
def _prepare_run(
|
|
116
|
+
self,
|
|
117
|
+
overrides: NsJailConfig | None,
|
|
118
|
+
override_fields: set[str] | None,
|
|
119
|
+
extra_args: list[str] | None,
|
|
120
|
+
) -> tuple[list[str], Path | None, NsJailConfig]:
|
|
121
|
+
"""Resolve binary, merge configs, and render to args.
|
|
122
|
+
|
|
123
|
+
Returns (nsjail_args, config_path, merged_cfg).
|
|
124
|
+
config_path is None when render_mode is 'cli'.
|
|
125
|
+
"""
|
|
126
|
+
nsjail_bin = resolve_nsjail_path(self._nsjail_path)
|
|
127
|
+
|
|
128
|
+
if overrides is not None and override_fields:
|
|
129
|
+
cfg = merge_configs(
|
|
130
|
+
self._base_config, overrides,
|
|
131
|
+
override_fields=override_fields, extra_args=extra_args,
|
|
132
|
+
)
|
|
133
|
+
elif extra_args:
|
|
134
|
+
cfg = merge_configs(
|
|
135
|
+
self._base_config, NsJailConfig(),
|
|
136
|
+
override_fields=set(), extra_args=extra_args,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
cfg = copy.deepcopy(self._base_config)
|
|
140
|
+
|
|
141
|
+
config_path: Path | None = None
|
|
142
|
+
if self._render_mode == "textproto":
|
|
143
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
144
|
+
mode="w", suffix=".cfg", delete=False, prefix="nsjail_"
|
|
145
|
+
)
|
|
146
|
+
config_path = Path(tmp.name)
|
|
147
|
+
to_file(cfg, config_path)
|
|
148
|
+
tmp.close()
|
|
149
|
+
nsjail_args = [str(nsjail_bin), "--config", str(config_path)]
|
|
150
|
+
else:
|
|
151
|
+
from nsjail.serializers.cli import to_cli_args
|
|
152
|
+
cli_args = to_cli_args(cfg, on_unsupported="skip")
|
|
153
|
+
nsjail_args = [str(nsjail_bin)] + cli_args
|
|
154
|
+
|
|
155
|
+
if cfg.exec_bin and self._render_mode == "cli":
|
|
156
|
+
nsjail_args.append("--")
|
|
157
|
+
nsjail_args.append(cfg.exec_bin.path)
|
|
158
|
+
nsjail_args.extend(cfg.exec_bin.arg)
|
|
159
|
+
|
|
160
|
+
return nsjail_args, config_path, cfg
|
|
161
|
+
|
|
162
|
+
def _make_result(
|
|
163
|
+
self,
|
|
164
|
+
returncode: int,
|
|
165
|
+
stdout: bytes,
|
|
166
|
+
stderr: bytes,
|
|
167
|
+
config_path: Path | None,
|
|
168
|
+
nsjail_args: list[str],
|
|
169
|
+
) -> NsJailResult:
|
|
170
|
+
"""Build an NsJailResult from raw subprocess output."""
|
|
171
|
+
timed_out = returncode == 109
|
|
172
|
+
signaled = returncode > 100 and not timed_out
|
|
173
|
+
oom_killed = returncode == 137
|
|
174
|
+
|
|
175
|
+
return NsJailResult(
|
|
176
|
+
returncode=returncode,
|
|
177
|
+
stdout=stdout,
|
|
178
|
+
stderr=stderr,
|
|
179
|
+
config_path=config_path,
|
|
180
|
+
nsjail_args=nsjail_args,
|
|
181
|
+
timed_out=timed_out,
|
|
182
|
+
oom_killed=oom_killed,
|
|
183
|
+
signaled=signaled,
|
|
184
|
+
inner_returncode=returncode if returncode < 100 else None,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
def run(
|
|
188
|
+
self,
|
|
189
|
+
overrides: NsJailConfig | None = None,
|
|
190
|
+
*,
|
|
191
|
+
override_fields: set[str] | None = None,
|
|
192
|
+
extra_args: list[str] | None = None,
|
|
193
|
+
timeout: float | None = None,
|
|
194
|
+
) -> NsJailResult:
|
|
195
|
+
nsjail_args, config_path, _cfg = self._prepare_run(overrides, override_fields, extra_args)
|
|
196
|
+
|
|
197
|
+
try:
|
|
198
|
+
result = subprocess.run(
|
|
199
|
+
nsjail_args,
|
|
200
|
+
capture_output=self._capture_output,
|
|
201
|
+
timeout=timeout,
|
|
202
|
+
)
|
|
203
|
+
finally:
|
|
204
|
+
if config_path and not self._keep_config:
|
|
205
|
+
config_path.unlink(missing_ok=True)
|
|
206
|
+
config_path = None
|
|
207
|
+
|
|
208
|
+
return self._make_result(
|
|
209
|
+
returncode=result.returncode,
|
|
210
|
+
stdout=result.stdout if self._capture_output else b"",
|
|
211
|
+
stderr=result.stderr if self._capture_output else b"",
|
|
212
|
+
config_path=config_path,
|
|
213
|
+
nsjail_args=nsjail_args,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def async_run(
|
|
217
|
+
self,
|
|
218
|
+
overrides: NsJailConfig | None = None,
|
|
219
|
+
*,
|
|
220
|
+
override_fields: set[str] | None = None,
|
|
221
|
+
extra_args: list[str] | None = None,
|
|
222
|
+
timeout: float | None = None,
|
|
223
|
+
) -> NsJailResult:
|
|
224
|
+
"""Run nsjail asynchronously."""
|
|
225
|
+
nsjail_args, config_path, cfg = self._prepare_run(
|
|
226
|
+
overrides, override_fields, extra_args
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
proc = await asyncio.create_subprocess_exec(
|
|
231
|
+
*nsjail_args,
|
|
232
|
+
stdout=asyncio.subprocess.PIPE if self._capture_output else None,
|
|
233
|
+
stderr=asyncio.subprocess.PIPE if self._capture_output else None,
|
|
234
|
+
)
|
|
235
|
+
if timeout is not None:
|
|
236
|
+
stdout, stderr = await asyncio.wait_for(
|
|
237
|
+
proc.communicate(), timeout=timeout
|
|
238
|
+
)
|
|
239
|
+
else:
|
|
240
|
+
stdout, stderr = await proc.communicate()
|
|
241
|
+
finally:
|
|
242
|
+
if config_path and not self._keep_config:
|
|
243
|
+
config_path.unlink(missing_ok=True)
|
|
244
|
+
config_path = None
|
|
245
|
+
|
|
246
|
+
return self._make_result(
|
|
247
|
+
proc.returncode,
|
|
248
|
+
stdout if self._capture_output else b"",
|
|
249
|
+
stderr if self._capture_output else b"",
|
|
250
|
+
config_path,
|
|
251
|
+
nsjail_args,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def fork(
|
|
255
|
+
self,
|
|
256
|
+
*,
|
|
257
|
+
overrides: NsJailConfig | None = None,
|
|
258
|
+
override_fields: set[str] | None = None,
|
|
259
|
+
nsjail_path: str | None = None,
|
|
260
|
+
) -> Runner:
|
|
261
|
+
if overrides and override_fields:
|
|
262
|
+
new_base = merge_configs(
|
|
263
|
+
self._base_config, overrides, override_fields=override_fields
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
new_base = copy.deepcopy(self._base_config)
|
|
267
|
+
|
|
268
|
+
return Runner(
|
|
269
|
+
nsjail_path=nsjail_path or self._nsjail_path,
|
|
270
|
+
base_config=new_base,
|
|
271
|
+
render_mode=self._render_mode,
|
|
272
|
+
capture_output=self._capture_output,
|
|
273
|
+
keep_config=self._keep_config,
|
|
274
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Serializers for NsJailConfig: textproto, CLI args, protobuf."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from nsjail.serializers.textproto import to_textproto
|
|
9
|
+
from nsjail.serializers.cli import to_cli_args
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_file(cfg: Any, path: str | Path, *, validate: bool = False) -> None:
|
|
13
|
+
if validate:
|
|
14
|
+
try:
|
|
15
|
+
from nsjail.serializers.protobuf import to_protobuf
|
|
16
|
+
except ImportError:
|
|
17
|
+
raise ImportError(
|
|
18
|
+
"Validation requires the protobuf extra: pip install nsjail-python[proto]"
|
|
19
|
+
) from None
|
|
20
|
+
to_protobuf(cfg)
|
|
21
|
+
|
|
22
|
+
text = to_textproto(cfg)
|
|
23
|
+
Path(path).write_text(text + "\n")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["to_textproto", "to_cli_args", "to_file"]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Serialize NsJailConfig to nsjail CLI arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from dataclasses import fields as dc_fields
|
|
7
|
+
from enum import IntEnum
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from nsjail._field_meta import FIELD_REGISTRY
|
|
11
|
+
from nsjail.exceptions import UnsupportedCLIField
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def to_cli_args(
|
|
17
|
+
cfg: Any,
|
|
18
|
+
*,
|
|
19
|
+
on_unsupported: Literal["raise", "warn", "skip"] = "raise",
|
|
20
|
+
) -> list[str]:
|
|
21
|
+
args: list[str] = []
|
|
22
|
+
cls_name = type(cfg).__name__
|
|
23
|
+
|
|
24
|
+
for f in dc_fields(cfg):
|
|
25
|
+
key = (cls_name, f.name)
|
|
26
|
+
meta = FIELD_REGISTRY.get(key)
|
|
27
|
+
if meta is None:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
value = getattr(cfg, f.name)
|
|
31
|
+
|
|
32
|
+
# Skip defaults and None
|
|
33
|
+
if meta.is_repeated:
|
|
34
|
+
if not value:
|
|
35
|
+
continue
|
|
36
|
+
elif meta.is_message:
|
|
37
|
+
if value is None:
|
|
38
|
+
continue
|
|
39
|
+
else:
|
|
40
|
+
from nsjail.serializers.textproto import _is_default
|
|
41
|
+
if _is_default(value, meta):
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
# Check CLI support
|
|
45
|
+
if not meta.cli_supported or meta.cli_flag is None:
|
|
46
|
+
if on_unsupported == "raise":
|
|
47
|
+
raise UnsupportedCLIField(f.name)
|
|
48
|
+
elif on_unsupported == "warn":
|
|
49
|
+
logger.warning(
|
|
50
|
+
"Config field %r has no CLI equivalent, skipping", f.name
|
|
51
|
+
)
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
# Render the field
|
|
55
|
+
if meta.is_repeated:
|
|
56
|
+
for item in value:
|
|
57
|
+
args.append(meta.cli_flag)
|
|
58
|
+
args.append(str(item))
|
|
59
|
+
elif isinstance(value, bool):
|
|
60
|
+
if value:
|
|
61
|
+
args.append(meta.cli_flag)
|
|
62
|
+
elif isinstance(value, IntEnum):
|
|
63
|
+
args.append(meta.cli_flag)
|
|
64
|
+
args.append(str(int(value)))
|
|
65
|
+
else:
|
|
66
|
+
args.append(meta.cli_flag)
|
|
67
|
+
args.append(str(value))
|
|
68
|
+
|
|
69
|
+
return args
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Convert NsJailConfig dataclass to compiled protobuf message.
|
|
2
|
+
|
|
3
|
+
Requires the [proto] extra: pip install nsjail-python[proto]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from google.protobuf import text_format
|
|
11
|
+
|
|
12
|
+
from nsjail.serializers.textproto import to_textproto
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_protobuf(cfg: Any) -> Any:
|
|
16
|
+
"""Convert a NsJailConfig to a compiled protobuf message."""
|
|
17
|
+
from nsjail._proto import config_pb2
|
|
18
|
+
|
|
19
|
+
text = to_textproto(cfg)
|
|
20
|
+
msg = config_pb2.NsJailConfig()
|
|
21
|
+
text_format.Parse(text, msg)
|
|
22
|
+
return msg
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Pure Python serializer for protobuf text format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import fields as dc_fields
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from nsjail._field_meta import FIELD_REGISTRY, FieldMeta
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def to_textproto(obj: Any, indent: int = 0) -> str:
|
|
13
|
+
lines: list[str] = []
|
|
14
|
+
prefix = " " * indent
|
|
15
|
+
cls_name = type(obj).__name__
|
|
16
|
+
|
|
17
|
+
for f in dc_fields(obj):
|
|
18
|
+
key = (cls_name, f.name)
|
|
19
|
+
meta = FIELD_REGISTRY.get(key)
|
|
20
|
+
if meta is None:
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
value = getattr(obj, f.name)
|
|
24
|
+
|
|
25
|
+
if meta.is_repeated:
|
|
26
|
+
if not value:
|
|
27
|
+
continue
|
|
28
|
+
if meta.is_message:
|
|
29
|
+
for item in value:
|
|
30
|
+
lines.append(f"{prefix}{f.name} {{")
|
|
31
|
+
inner = to_textproto(item, indent + 1)
|
|
32
|
+
if inner.strip():
|
|
33
|
+
lines.append(inner)
|
|
34
|
+
lines.append(f"{prefix}}}")
|
|
35
|
+
else:
|
|
36
|
+
for item in value:
|
|
37
|
+
lines.append(f"{prefix}{f.name}: {_format_scalar(item, meta)}")
|
|
38
|
+
elif meta.is_message:
|
|
39
|
+
if value is None:
|
|
40
|
+
continue
|
|
41
|
+
lines.append(f"{prefix}{f.name} {{")
|
|
42
|
+
inner = to_textproto(value, indent + 1)
|
|
43
|
+
if inner.strip():
|
|
44
|
+
lines.append(inner)
|
|
45
|
+
lines.append(f"{prefix}}}")
|
|
46
|
+
else:
|
|
47
|
+
if _is_default(value, meta):
|
|
48
|
+
continue
|
|
49
|
+
lines.append(f"{prefix}{f.name}: {_format_scalar(value, meta)}")
|
|
50
|
+
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_default(value: Any, meta: FieldMeta) -> bool:
|
|
55
|
+
if value is None and meta.default is None:
|
|
56
|
+
return True
|
|
57
|
+
if value is None:
|
|
58
|
+
return False
|
|
59
|
+
if meta.default is None:
|
|
60
|
+
return False
|
|
61
|
+
if isinstance(value, IntEnum):
|
|
62
|
+
return int(value) == meta.default
|
|
63
|
+
return value == meta.default
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _format_scalar(value: Any, meta: FieldMeta) -> str:
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return "true" if value else "false"
|
|
69
|
+
if isinstance(value, IntEnum):
|
|
70
|
+
return value.name
|
|
71
|
+
if isinstance(value, int):
|
|
72
|
+
return str(value)
|
|
73
|
+
if isinstance(value, str):
|
|
74
|
+
return f'"{_escape_string(value)}"'
|
|
75
|
+
if isinstance(value, bytes):
|
|
76
|
+
return f'"{_escape_bytes(value)}"'
|
|
77
|
+
return str(value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _escape_string(s: str) -> str:
|
|
81
|
+
return s.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _escape_bytes(b: bytes) -> str:
|
|
85
|
+
parts: list[str] = []
|
|
86
|
+
for byte in b:
|
|
87
|
+
if 32 <= byte < 127 and byte != ord("\\") and byte != ord('"'):
|
|
88
|
+
parts.append(chr(byte))
|
|
89
|
+
else:
|
|
90
|
+
parts.append(f"\\x{byte:02x}")
|
|
91
|
+
return "".join(parts)
|