chorut 0.1.4__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.
- chorut/__init__.py +755 -0
- chorut/__main__.py +9 -0
- chorut-0.1.4.dist-info/METADATA +341 -0
- chorut-0.1.4.dist-info/RECORD +7 -0
- chorut-0.1.4.dist-info/WHEEL +4 -0
- chorut-0.1.4.dist-info/entry_points.txt +2 -0
- chorut-0.1.4.dist-info/licenses/LICENSE +21 -0
chorut/__init__.py
ADDED
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python library for chroot functionality.
|
|
3
|
+
|
|
4
|
+
This library provides the ability to set up and manage chroot environments
|
|
5
|
+
using only Python standard library modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Type alias for mount specifications
|
|
21
|
+
MountSpec = dict[str, Any]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChrootError(Exception):
|
|
25
|
+
"""Exception raised for chroot-related errors."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MountError(Exception):
|
|
31
|
+
"""Exception raised for mount-related errors."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MountManager:
|
|
37
|
+
"""Manages filesystem mounts for chroot environments."""
|
|
38
|
+
|
|
39
|
+
def __init__(self):
|
|
40
|
+
self.active_mounts: list[str] = []
|
|
41
|
+
self.active_lazy: list[str] = []
|
|
42
|
+
self.active_files: list[str] = []
|
|
43
|
+
|
|
44
|
+
def mount(
|
|
45
|
+
self, source: str, target: str, fstype: str | None = None, options: str | None = None, bind: bool = False
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Mount a filesystem and track it for cleanup."""
|
|
48
|
+
cmd = ["mount"]
|
|
49
|
+
|
|
50
|
+
if bind:
|
|
51
|
+
cmd.append("--bind")
|
|
52
|
+
elif fstype:
|
|
53
|
+
cmd.extend(["-t", fstype])
|
|
54
|
+
|
|
55
|
+
if options:
|
|
56
|
+
cmd.extend(["-o", options])
|
|
57
|
+
|
|
58
|
+
cmd.extend([source, target])
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
62
|
+
self.active_mounts.insert(0, target) # Insert at beginning for reverse order unmount
|
|
63
|
+
logger.debug(f"Mounted {source} at {target}")
|
|
64
|
+
except subprocess.CalledProcessError as e:
|
|
65
|
+
raise MountError(f"Failed to mount {source} at {target}: {e.stderr}") from None
|
|
66
|
+
|
|
67
|
+
def mount_lazy(self, source: str, target: str, bind: bool = False) -> None:
|
|
68
|
+
"""Mount with lazy unmount tracking."""
|
|
69
|
+
self.mount(source, target, bind=bind)
|
|
70
|
+
# Move from active_mounts to active_lazy
|
|
71
|
+
if target in self.active_mounts:
|
|
72
|
+
self.active_mounts.remove(target)
|
|
73
|
+
self.active_lazy.insert(0, target)
|
|
74
|
+
|
|
75
|
+
def bind_device(self, source: str, target: str) -> None:
|
|
76
|
+
"""Bind mount a device file."""
|
|
77
|
+
# Create the target file
|
|
78
|
+
Path(target).touch()
|
|
79
|
+
self.active_files.insert(0, target)
|
|
80
|
+
self.mount(source, target, bind=True)
|
|
81
|
+
|
|
82
|
+
def create_symlink(self, source: str, target: str) -> None:
|
|
83
|
+
"""Create a symbolic link and track it for cleanup."""
|
|
84
|
+
try:
|
|
85
|
+
os.symlink(source, target)
|
|
86
|
+
self.active_files.insert(0, target)
|
|
87
|
+
logger.debug(f"Created symlink {target} -> {source}")
|
|
88
|
+
except OSError as e:
|
|
89
|
+
raise MountError(f"Failed to create symlink {target} -> {source}: {e}") from None
|
|
90
|
+
|
|
91
|
+
def unmount_all(self) -> None:
|
|
92
|
+
"""Unmount all tracked mounts."""
|
|
93
|
+
# Unmount regular mounts
|
|
94
|
+
for mount_point in self.active_mounts:
|
|
95
|
+
try:
|
|
96
|
+
subprocess.run(["umount", mount_point], check=True, capture_output=True)
|
|
97
|
+
logger.debug(f"Unmounted {mount_point}")
|
|
98
|
+
except subprocess.CalledProcessError as e:
|
|
99
|
+
logger.warning(f"Failed to unmount {mount_point}: {e.stderr}")
|
|
100
|
+
|
|
101
|
+
# Lazy unmount
|
|
102
|
+
for mount_point in self.active_lazy:
|
|
103
|
+
try:
|
|
104
|
+
subprocess.run(["umount", "--lazy", mount_point], check=True, capture_output=True)
|
|
105
|
+
logger.debug(f"Lazy unmounted {mount_point}")
|
|
106
|
+
except subprocess.CalledProcessError as e:
|
|
107
|
+
logger.warning(f"Failed to lazy unmount {mount_point}: {e.stderr}")
|
|
108
|
+
|
|
109
|
+
# Remove created files/symlinks
|
|
110
|
+
for file_path in self.active_files:
|
|
111
|
+
try:
|
|
112
|
+
os.unlink(file_path)
|
|
113
|
+
logger.debug(f"Removed {file_path}")
|
|
114
|
+
except OSError as e:
|
|
115
|
+
logger.warning(f"Failed to remove {file_path}: {e}")
|
|
116
|
+
|
|
117
|
+
# Clear tracking lists
|
|
118
|
+
self.active_mounts.clear()
|
|
119
|
+
self.active_lazy.clear()
|
|
120
|
+
self.active_files.clear()
|
|
121
|
+
|
|
122
|
+
def __enter__(self):
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
126
|
+
self.unmount_all()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class ChrootManager:
|
|
130
|
+
"""Manages chroot environments with proper mount setup and cleanup."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
chroot_dir: str | Path,
|
|
135
|
+
unshare_mode: bool = False,
|
|
136
|
+
custom_mounts: list[MountSpec] | None = None,
|
|
137
|
+
auto_shell: bool = True,
|
|
138
|
+
):
|
|
139
|
+
"""
|
|
140
|
+
Initialize the chroot manager.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
chroot_dir: Path to the chroot directory
|
|
144
|
+
unshare_mode: Whether to use unshare mode (for non-root users)
|
|
145
|
+
custom_mounts: Optional list of custom mount specifications
|
|
146
|
+
Each mount spec is a dict with keys:
|
|
147
|
+
- source: Source path/device (required)
|
|
148
|
+
- target: Target path relative to chroot (required)
|
|
149
|
+
- fstype: Filesystem type (optional, defaults to auto-detect)
|
|
150
|
+
- options: Mount options (optional)
|
|
151
|
+
- bind: Whether this is a bind mount (optional, defaults to False)
|
|
152
|
+
- mkdir: Whether to create target directory (optional, defaults to True)
|
|
153
|
+
auto_shell: Whether to automatically detect shell features in string commands
|
|
154
|
+
and wrap them with 'bash -c' (default: True)
|
|
155
|
+
"""
|
|
156
|
+
self.chroot_dir = Path(chroot_dir).resolve()
|
|
157
|
+
self.unshare_mode = unshare_mode
|
|
158
|
+
self.custom_mounts = custom_mounts or []
|
|
159
|
+
self.auto_shell = auto_shell
|
|
160
|
+
self.mount_manager = MountManager()
|
|
161
|
+
self._is_setup = False
|
|
162
|
+
|
|
163
|
+
def _check_root(self) -> None:
|
|
164
|
+
"""Check if running as root (required for normal mode)."""
|
|
165
|
+
if not self.unshare_mode and os.getuid() != 0:
|
|
166
|
+
raise ChrootError("This operation requires root privileges. Use unshare_mode=True for non-root operation.")
|
|
167
|
+
|
|
168
|
+
def _check_chroot_dir(self) -> None:
|
|
169
|
+
"""Validate the chroot directory."""
|
|
170
|
+
if not self.chroot_dir.is_dir():
|
|
171
|
+
raise ChrootError(f"Chroot directory does not exist: {self.chroot_dir}")
|
|
172
|
+
|
|
173
|
+
def _setup_standard_mounts(self) -> None:
|
|
174
|
+
"""Set up standard filesystem mounts for chroot."""
|
|
175
|
+
proc_dir = self.chroot_dir / "proc"
|
|
176
|
+
sys_dir = self.chroot_dir / "sys"
|
|
177
|
+
dev_dir = self.chroot_dir / "dev"
|
|
178
|
+
|
|
179
|
+
# Create directories if they don't exist
|
|
180
|
+
proc_dir.mkdir(exist_ok=True)
|
|
181
|
+
sys_dir.mkdir(exist_ok=True)
|
|
182
|
+
dev_dir.mkdir(exist_ok=True)
|
|
183
|
+
|
|
184
|
+
# Mount proc
|
|
185
|
+
self.mount_manager.mount("proc", str(proc_dir), fstype="proc", options="nosuid,noexec,nodev")
|
|
186
|
+
|
|
187
|
+
# Mount sys
|
|
188
|
+
self.mount_manager.mount("sys", str(sys_dir), fstype="sysfs", options="nosuid,noexec,nodev,ro")
|
|
189
|
+
|
|
190
|
+
# Mount efivarfs if available
|
|
191
|
+
efivarfs_dir = sys_dir / "firmware/efi/efivars"
|
|
192
|
+
if efivarfs_dir.exists():
|
|
193
|
+
with contextlib.suppress(MountError):
|
|
194
|
+
self.mount_manager.mount(
|
|
195
|
+
"efivarfs", str(efivarfs_dir), fstype="efivarfs", options="nosuid,noexec,nodev"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
# Mount dev
|
|
199
|
+
self.mount_manager.mount("udev", str(dev_dir), fstype="devtmpfs", options="mode=0755,nosuid")
|
|
200
|
+
|
|
201
|
+
# Mount devpts
|
|
202
|
+
devpts_dir = dev_dir / "pts"
|
|
203
|
+
devpts_dir.mkdir(exist_ok=True)
|
|
204
|
+
self.mount_manager.mount("devpts", str(devpts_dir), fstype="devpts", options="mode=0620,gid=5,nosuid,noexec")
|
|
205
|
+
|
|
206
|
+
# Mount shm
|
|
207
|
+
shm_dir = dev_dir / "shm"
|
|
208
|
+
shm_dir.mkdir(exist_ok=True)
|
|
209
|
+
self.mount_manager.mount("shm", str(shm_dir), fstype="tmpfs", options="mode=1777,nosuid,nodev")
|
|
210
|
+
|
|
211
|
+
# Mount run
|
|
212
|
+
run_dir = self.chroot_dir / "run"
|
|
213
|
+
run_dir.mkdir(exist_ok=True)
|
|
214
|
+
self.mount_manager.mount("run", str(run_dir), fstype="tmpfs", options="nosuid,nodev,mode=0755")
|
|
215
|
+
|
|
216
|
+
# Mount tmp
|
|
217
|
+
tmp_dir = self.chroot_dir / "tmp"
|
|
218
|
+
tmp_dir.mkdir(exist_ok=True)
|
|
219
|
+
self.mount_manager.mount("tmp", str(tmp_dir), fstype="tmpfs", options="mode=1777,strictatime,nodev,nosuid")
|
|
220
|
+
|
|
221
|
+
def _setup_unshare_mounts(self) -> None:
|
|
222
|
+
"""Set up mounts for unshare mode."""
|
|
223
|
+
# Bind mount the chroot directory to itself
|
|
224
|
+
self.mount_manager.mount_lazy(str(self.chroot_dir), str(self.chroot_dir), bind=True)
|
|
225
|
+
|
|
226
|
+
# Mount proc
|
|
227
|
+
proc_dir = self.chroot_dir / "proc"
|
|
228
|
+
proc_dir.mkdir(exist_ok=True)
|
|
229
|
+
self.mount_manager.mount("proc", str(proc_dir), fstype="proc", options="nosuid,noexec,nodev")
|
|
230
|
+
|
|
231
|
+
# Recursive bind mount sys
|
|
232
|
+
sys_dir = self.chroot_dir / "sys"
|
|
233
|
+
sys_dir.mkdir(exist_ok=True)
|
|
234
|
+
self.mount_manager.mount_lazy("/sys", str(sys_dir), bind=True)
|
|
235
|
+
|
|
236
|
+
# Create device symlinks
|
|
237
|
+
dev_dir = self.chroot_dir / "dev"
|
|
238
|
+
dev_dir.mkdir(exist_ok=True)
|
|
239
|
+
|
|
240
|
+
self.mount_manager.create_symlink("/proc/self/fd", str(dev_dir / "fd"))
|
|
241
|
+
self.mount_manager.create_symlink("/proc/self/fd/0", str(dev_dir / "stdin"))
|
|
242
|
+
self.mount_manager.create_symlink("/proc/self/fd/1", str(dev_dir / "stdout"))
|
|
243
|
+
self.mount_manager.create_symlink("/proc/self/fd/2", str(dev_dir / "stderr"))
|
|
244
|
+
|
|
245
|
+
# Bind mount essential devices
|
|
246
|
+
for device in ["full", "null", "random", "tty", "urandom", "zero"]:
|
|
247
|
+
self.mount_manager.bind_device(f"/dev/{device}", str(dev_dir / device))
|
|
248
|
+
|
|
249
|
+
# Mount run and tmp
|
|
250
|
+
run_dir = self.chroot_dir / "run"
|
|
251
|
+
run_dir.mkdir(exist_ok=True)
|
|
252
|
+
self.mount_manager.mount("run", str(run_dir), fstype="tmpfs", options="nosuid,nodev,mode=0755")
|
|
253
|
+
|
|
254
|
+
tmp_dir = self.chroot_dir / "tmp"
|
|
255
|
+
tmp_dir.mkdir(exist_ok=True)
|
|
256
|
+
self.mount_manager.mount("tmp", str(tmp_dir), fstype="tmpfs", options="mode=1777,strictatime,nodev,nosuid")
|
|
257
|
+
|
|
258
|
+
def _resolve_link(self, path: str, root: str | None = None) -> str:
|
|
259
|
+
"""Resolve symbolic links, similar to the bash version."""
|
|
260
|
+
target = path
|
|
261
|
+
if root and not root.endswith("/"):
|
|
262
|
+
root = root + "/"
|
|
263
|
+
|
|
264
|
+
while os.path.islink(target):
|
|
265
|
+
target = os.readlink(target)
|
|
266
|
+
if not os.path.isabs(target):
|
|
267
|
+
target = os.path.join(os.path.dirname(path), target)
|
|
268
|
+
target = os.path.normpath(target)
|
|
269
|
+
|
|
270
|
+
if root and not target.startswith(root):
|
|
271
|
+
target = root + target.lstrip("/")
|
|
272
|
+
|
|
273
|
+
return target
|
|
274
|
+
|
|
275
|
+
def _needs_shell(self, command_str: str) -> bool:
|
|
276
|
+
"""
|
|
277
|
+
Detect if a command string contains shell metacharacters that require bash -c wrapping.
|
|
278
|
+
|
|
279
|
+
Returns True if the command contains shell features like pipes, redirects,
|
|
280
|
+
command substitution, logical operators, etc.
|
|
281
|
+
"""
|
|
282
|
+
import re
|
|
283
|
+
|
|
284
|
+
# Shell metacharacters that require shell interpretation
|
|
285
|
+
shell_patterns = [
|
|
286
|
+
r"\|", # Pipes: cmd1 | cmd2
|
|
287
|
+
r"&&", # Logical AND: cmd1 && cmd2
|
|
288
|
+
r"\|\|", # Logical OR: cmd1 || cmd2
|
|
289
|
+
r"[;&]", # Command separators: cmd1; cmd2 or cmd1 & cmd2
|
|
290
|
+
r"[<>]", # Redirects: cmd > file, cmd < file
|
|
291
|
+
r"`[^`]*`", # Command substitution: `cmd`
|
|
292
|
+
r"\$\([^)]*\)", # Command substitution: $(cmd)
|
|
293
|
+
r"\*", # Glob patterns: *.txt
|
|
294
|
+
r"\?", # Glob patterns: file?.txt
|
|
295
|
+
r"~", # Home directory expansion
|
|
296
|
+
r"\$\w+", # Variable expansion: $VAR
|
|
297
|
+
r"\{[^}]*\}", # Brace expansion: {a,b,c}
|
|
298
|
+
]
|
|
299
|
+
|
|
300
|
+
# Skip detection if already wrapped with bash -c
|
|
301
|
+
if command_str.strip().startswith(("bash -c", "sh -c")):
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
# Check for any shell metacharacters outside of quotes
|
|
305
|
+
# This is a simplified approach - a more robust version would need
|
|
306
|
+
# proper quote-aware parsing
|
|
307
|
+
return any(re.search(pattern, command_str) for pattern in shell_patterns)
|
|
308
|
+
|
|
309
|
+
def _setup_custom_mounts(self) -> None:
|
|
310
|
+
"""Set up user-defined custom mounts."""
|
|
311
|
+
for mount_spec in self.custom_mounts:
|
|
312
|
+
try:
|
|
313
|
+
# Validate required fields
|
|
314
|
+
if "source" not in mount_spec:
|
|
315
|
+
raise MountError("Mount specification missing required 'source' field")
|
|
316
|
+
if "target" not in mount_spec:
|
|
317
|
+
raise MountError("Mount specification missing required 'target' field")
|
|
318
|
+
|
|
319
|
+
source = mount_spec["source"]
|
|
320
|
+
target_rel = mount_spec["target"].lstrip("/") # Remove leading slash for relative path
|
|
321
|
+
target = str(self.chroot_dir / target_rel)
|
|
322
|
+
|
|
323
|
+
# Get optional parameters
|
|
324
|
+
fstype = mount_spec.get("fstype")
|
|
325
|
+
options = mount_spec.get("options")
|
|
326
|
+
bind = mount_spec.get("bind", False)
|
|
327
|
+
mkdir = mount_spec.get("mkdir", True)
|
|
328
|
+
|
|
329
|
+
# Create target directory if requested
|
|
330
|
+
if mkdir:
|
|
331
|
+
Path(target).mkdir(parents=True, exist_ok=True)
|
|
332
|
+
|
|
333
|
+
# Perform the mount
|
|
334
|
+
self.mount_manager.mount(source, target, fstype=fstype, options=options, bind=bind)
|
|
335
|
+
logger.debug(f"Custom mount: {source} -> {target}")
|
|
336
|
+
|
|
337
|
+
except Exception as e:
|
|
338
|
+
logger.error(f"Failed to setup custom mount {mount_spec}: {e}")
|
|
339
|
+
raise MountError(f"Failed to setup custom mount: {e}") from None
|
|
340
|
+
|
|
341
|
+
def _setup_resolv_conf(self) -> None:
|
|
342
|
+
"""Set up resolv.conf in the chroot."""
|
|
343
|
+
host_resolv = "/etc/resolv.conf"
|
|
344
|
+
chroot_resolv = self.chroot_dir / "etc/resolv.conf"
|
|
345
|
+
|
|
346
|
+
# Resolve symbolic links
|
|
347
|
+
src = self._resolve_link(host_resolv)
|
|
348
|
+
dest = self._resolve_link(str(chroot_resolv), str(self.chroot_dir))
|
|
349
|
+
|
|
350
|
+
if not os.path.exists(src):
|
|
351
|
+
return # No source resolv.conf
|
|
352
|
+
|
|
353
|
+
if not os.path.exists(dest):
|
|
354
|
+
if dest == str(chroot_resolv):
|
|
355
|
+
return # No resolv.conf needed in chroot
|
|
356
|
+
|
|
357
|
+
# Create dummy file for binding
|
|
358
|
+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
|
359
|
+
Path(dest).touch()
|
|
360
|
+
|
|
361
|
+
try:
|
|
362
|
+
self.mount_manager.mount(src, dest, bind=True)
|
|
363
|
+
except MountError as e:
|
|
364
|
+
logger.warning(f"Failed to setup resolv.conf: {e}")
|
|
365
|
+
|
|
366
|
+
def setup(self) -> None:
|
|
367
|
+
"""Set up the chroot environment."""
|
|
368
|
+
if self._is_setup:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
self._check_chroot_dir()
|
|
372
|
+
|
|
373
|
+
# For unshare mode, skip mount setup as it will be done in the unshared namespace
|
|
374
|
+
if not self.unshare_mode:
|
|
375
|
+
self._check_root()
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
self._setup_standard_mounts()
|
|
379
|
+
self._setup_resolv_conf()
|
|
380
|
+
self._setup_custom_mounts()
|
|
381
|
+
|
|
382
|
+
# Check if chroot_dir is a mountpoint
|
|
383
|
+
try:
|
|
384
|
+
result = subprocess.run(
|
|
385
|
+
["mountpoint", "-q", str(self.chroot_dir)], check=False, capture_output=True
|
|
386
|
+
)
|
|
387
|
+
if result.returncode != 0:
|
|
388
|
+
logger.warning(
|
|
389
|
+
f"{self.chroot_dir} is not a mountpoint. This may have undesirable side effects."
|
|
390
|
+
)
|
|
391
|
+
except FileNotFoundError:
|
|
392
|
+
pass # mountpoint command not available
|
|
393
|
+
|
|
394
|
+
except Exception as e:
|
|
395
|
+
self.teardown()
|
|
396
|
+
raise ChrootError(f"Failed to setup chroot: {e}") from None
|
|
397
|
+
|
|
398
|
+
self._is_setup = True
|
|
399
|
+
|
|
400
|
+
def teardown(self) -> None:
|
|
401
|
+
"""Tear down the chroot environment."""
|
|
402
|
+
if self._is_setup:
|
|
403
|
+
self.mount_manager.unmount_all()
|
|
404
|
+
self._is_setup = False
|
|
405
|
+
|
|
406
|
+
def _create_unshare_script(self, command: list[str], userspec: str | None = None) -> str:
|
|
407
|
+
"""Create a script to run within the unshared namespace."""
|
|
408
|
+
# Check if verbose logging is enabled
|
|
409
|
+
verbose = logger.isEnabledFor(logging.DEBUG)
|
|
410
|
+
|
|
411
|
+
script_lines = [
|
|
412
|
+
"#!/bin/bash",
|
|
413
|
+
"set -e",
|
|
414
|
+
"",
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
if verbose:
|
|
418
|
+
script_lines.extend(
|
|
419
|
+
[
|
|
420
|
+
"echo 'Setting up unshare chroot environment...'",
|
|
421
|
+
"echo 'Target directory: " + str(self.chroot_dir) + "'",
|
|
422
|
+
"",
|
|
423
|
+
]
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
script_lines.extend(
|
|
427
|
+
[
|
|
428
|
+
"# Set up basic directories",
|
|
429
|
+
f"cd '{self.chroot_dir}'",
|
|
430
|
+
]
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
if verbose:
|
|
434
|
+
script_lines.append("echo 'Creating directory structure...'")
|
|
435
|
+
|
|
436
|
+
script_lines.extend(
|
|
437
|
+
[
|
|
438
|
+
"mkdir -p proc sys dev dev/pts dev/shm run tmp",
|
|
439
|
+
"",
|
|
440
|
+
]
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
if verbose:
|
|
444
|
+
script_lines.append("echo 'Mounting essential filesystems...'")
|
|
445
|
+
|
|
446
|
+
script_lines.extend(
|
|
447
|
+
[
|
|
448
|
+
"# Mount essential filesystems",
|
|
449
|
+
"mount -t proc proc proc",
|
|
450
|
+
"mount --bind /sys sys 2>/dev/null || mkdir -p sys",
|
|
451
|
+
"mount -t tmpfs udev dev",
|
|
452
|
+
"mkdir -p dev/pts dev/shm",
|
|
453
|
+
"mount -t devpts devpts dev/pts -o mode=0620,gid=5,nosuid,noexec",
|
|
454
|
+
"mount -t tmpfs shm dev/shm -o mode=1777,nosuid,nodev",
|
|
455
|
+
"mount -t tmpfs run run -o nosuid,nodev,mode=0755",
|
|
456
|
+
"mount -t tmpfs tmp tmp -o mode=1777,strictatime,nodev,nosuid",
|
|
457
|
+
"",
|
|
458
|
+
]
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if verbose:
|
|
462
|
+
script_lines.append("echo 'Setting up device files...'")
|
|
463
|
+
|
|
464
|
+
script_lines.extend(
|
|
465
|
+
[
|
|
466
|
+
"# Create device symlinks",
|
|
467
|
+
"ln -sf /proc/self/fd dev/fd",
|
|
468
|
+
"ln -sf /proc/self/fd/0 dev/stdin",
|
|
469
|
+
"ln -sf /proc/self/fd/1 dev/stdout",
|
|
470
|
+
"ln -sf /proc/self/fd/2 dev/stderr",
|
|
471
|
+
"",
|
|
472
|
+
"# Create essential device files",
|
|
473
|
+
]
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
for device in ["full", "null", "random", "tty", "urandom", "zero"]:
|
|
477
|
+
script_lines.append(f"touch dev/{device}")
|
|
478
|
+
script_lines.append(f"mount --bind /dev/{device} dev/{device}")
|
|
479
|
+
|
|
480
|
+
script_lines.extend(
|
|
481
|
+
[
|
|
482
|
+
"",
|
|
483
|
+
"# Set up resolv.conf if available",
|
|
484
|
+
"if [ -f /etc/resolv.conf ] && [ -d etc ]; then",
|
|
485
|
+
" mkdir -p etc",
|
|
486
|
+
" if [ ! -f etc/resolv.conf ]; then",
|
|
487
|
+
" touch etc/resolv.conf",
|
|
488
|
+
" fi",
|
|
489
|
+
" mount --bind /etc/resolv.conf etc/resolv.conf 2>/dev/null || true",
|
|
490
|
+
"fi",
|
|
491
|
+
"",
|
|
492
|
+
]
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Add custom mounts
|
|
496
|
+
if self.custom_mounts and verbose:
|
|
497
|
+
script_lines.append("echo 'Setting up custom mounts...'")
|
|
498
|
+
|
|
499
|
+
for mount_spec in self.custom_mounts:
|
|
500
|
+
source = mount_spec["source"]
|
|
501
|
+
target_rel = mount_spec["target"].lstrip("/")
|
|
502
|
+
fstype = mount_spec.get("fstype")
|
|
503
|
+
options = mount_spec.get("options")
|
|
504
|
+
bind = mount_spec.get("bind", False)
|
|
505
|
+
mkdir = mount_spec.get("mkdir", True)
|
|
506
|
+
|
|
507
|
+
if verbose:
|
|
508
|
+
script_lines.append(f"echo 'Mounting {source} -> {target_rel}'")
|
|
509
|
+
|
|
510
|
+
if mkdir:
|
|
511
|
+
script_lines.append(f"mkdir -p '{target_rel}'")
|
|
512
|
+
|
|
513
|
+
mount_cmd = ["mount"]
|
|
514
|
+
if bind:
|
|
515
|
+
mount_cmd.append("--bind")
|
|
516
|
+
elif fstype:
|
|
517
|
+
mount_cmd.extend(["-t", fstype])
|
|
518
|
+
|
|
519
|
+
if options:
|
|
520
|
+
mount_cmd.extend(["-o", options])
|
|
521
|
+
|
|
522
|
+
mount_cmd.extend([f"'{source}'", f"'{target_rel}'"])
|
|
523
|
+
script_lines.append(" ".join(mount_cmd))
|
|
524
|
+
|
|
525
|
+
script_lines.extend(
|
|
526
|
+
[
|
|
527
|
+
"",
|
|
528
|
+
]
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
if verbose:
|
|
532
|
+
script_lines.append("echo 'Entering chroot and executing command...'")
|
|
533
|
+
|
|
534
|
+
script_lines.append("# Execute the command in chroot")
|
|
535
|
+
|
|
536
|
+
chroot_cmd = ["chroot"]
|
|
537
|
+
if userspec:
|
|
538
|
+
chroot_cmd.extend(["--userspec", userspec])
|
|
539
|
+
chroot_cmd.append(".")
|
|
540
|
+
chroot_cmd.extend(f"'{arg}'" for arg in command)
|
|
541
|
+
|
|
542
|
+
script_lines.append(" ".join(chroot_cmd))
|
|
543
|
+
|
|
544
|
+
return "\n".join(script_lines)
|
|
545
|
+
|
|
546
|
+
def execute(
|
|
547
|
+
self,
|
|
548
|
+
command: list[str] | str | None = None,
|
|
549
|
+
userspec: str | None = None,
|
|
550
|
+
capture_output: bool = False,
|
|
551
|
+
text: bool = True,
|
|
552
|
+
) -> subprocess.CompletedProcess:
|
|
553
|
+
"""
|
|
554
|
+
Execute a command in the chroot environment.
|
|
555
|
+
|
|
556
|
+
Args:
|
|
557
|
+
command: Command to execute (defaults to ['/bin/bash']). Can be a list of strings or a single string.
|
|
558
|
+
When auto_shell=True (default), string commands containing shell metacharacters
|
|
559
|
+
(pipes |, logical operators &&/||, redirects <>, command substitution `cmd`/$(cmd),
|
|
560
|
+
glob patterns *, variable expansion $VAR, etc.) are automatically wrapped with 'bash -c'.
|
|
561
|
+
Simple commands are parsed with shlex.split().
|
|
562
|
+
Set auto_shell=False during initialization to disable this behavior and require explicit 'bash -c' wrapping.
|
|
563
|
+
userspec: User specification in format 'user' or 'user:group'
|
|
564
|
+
capture_output: If True, capture stdout and stderr. If False, output goes to the terminal (default: False)
|
|
565
|
+
text: If True, decode output as text. If False, return bytes (default: True)
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
CompletedProcess object with the result. When capture_output=True, the stdout and stderr
|
|
569
|
+
attributes will contain the captured output.
|
|
570
|
+
|
|
571
|
+
Examples:
|
|
572
|
+
# Simple commands (both formats work identically):
|
|
573
|
+
result = chroot.execute(["echo", "hello"])
|
|
574
|
+
result = chroot.execute("echo hello")
|
|
575
|
+
|
|
576
|
+
# Capture output:
|
|
577
|
+
result = chroot.execute("echo hello", capture_output=True)
|
|
578
|
+
print(f"Output: {result.stdout}")
|
|
579
|
+
print(f"Errors: {result.stderr}")
|
|
580
|
+
|
|
581
|
+
# Commands with quoted arguments:
|
|
582
|
+
result = chroot.execute("echo 'hello world'", capture_output=True)
|
|
583
|
+
|
|
584
|
+
# Shell features now work automatically (when auto_shell=True):
|
|
585
|
+
result = chroot.execute("ls | wc -l", capture_output=True) # Pipes
|
|
586
|
+
result = chroot.execute("echo hello && echo world", capture_output=True) # Logical operators
|
|
587
|
+
result = chroot.execute("echo `date`", capture_output=True) # Command substitution
|
|
588
|
+
result = chroot.execute("ls *.txt", capture_output=True) # Glob patterns
|
|
589
|
+
|
|
590
|
+
# Manual shell invocation still works:
|
|
591
|
+
result = chroot.execute("bash -c 'echo hello && echo world'", capture_output=True)
|
|
592
|
+
|
|
593
|
+
# Disable auto-detection by setting auto_shell=False during initialization:
|
|
594
|
+
chroot_manual = ChrootManager('/path', auto_shell=False)
|
|
595
|
+
result = chroot_manual.execute("bash -c 'ls | wc -l'") # Explicit bash -c needed
|
|
596
|
+
"""
|
|
597
|
+
if not self._is_setup:
|
|
598
|
+
raise ChrootError("Chroot environment not set up. Call setup() first.")
|
|
599
|
+
|
|
600
|
+
if command is None:
|
|
601
|
+
command = ["/bin/bash"]
|
|
602
|
+
elif isinstance(command, str):
|
|
603
|
+
import shlex
|
|
604
|
+
|
|
605
|
+
# Auto-detect shell features and wrap with bash -c if needed
|
|
606
|
+
if self.auto_shell and self._needs_shell(command):
|
|
607
|
+
logger.debug(f"Auto-detected shell features in command: {command}")
|
|
608
|
+
command = ["bash", "-c", command]
|
|
609
|
+
else:
|
|
610
|
+
command = shlex.split(command)
|
|
611
|
+
|
|
612
|
+
if self.unshare_mode:
|
|
613
|
+
# For unshare mode, create a script and run it in unshared namespace
|
|
614
|
+
logger.debug("Creating unshare script for command: %s", command)
|
|
615
|
+
script_content = self._create_unshare_script(command, userspec)
|
|
616
|
+
|
|
617
|
+
# Write script to a temporary file
|
|
618
|
+
import tempfile
|
|
619
|
+
|
|
620
|
+
with tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False) as f:
|
|
621
|
+
f.write(script_content)
|
|
622
|
+
script_path = f.name
|
|
623
|
+
|
|
624
|
+
logger.debug("Unshare script written to: %s", script_path)
|
|
625
|
+
if logger.isEnabledFor(logging.DEBUG):
|
|
626
|
+
logger.debug("Script content:\n%s", script_content)
|
|
627
|
+
|
|
628
|
+
try:
|
|
629
|
+
# Make script executable
|
|
630
|
+
os.chmod(script_path, 0o755)
|
|
631
|
+
|
|
632
|
+
# Run the script in unshared namespace
|
|
633
|
+
unshare_cmd = ["unshare", "--fork", "--pid", "--mount", "--map-auto", "--map-root-user", script_path]
|
|
634
|
+
|
|
635
|
+
logger.debug("Executing unshare command: %s", " ".join(unshare_cmd))
|
|
636
|
+
|
|
637
|
+
env = os.environ.copy()
|
|
638
|
+
env["SHELL"] = "/bin/bash"
|
|
639
|
+
|
|
640
|
+
return subprocess.run(unshare_cmd, check=False, env=env, capture_output=capture_output, text=text)
|
|
641
|
+
finally:
|
|
642
|
+
# Clean up script file
|
|
643
|
+
try:
|
|
644
|
+
os.unlink(script_path)
|
|
645
|
+
logger.debug("Cleaned up script file: %s", script_path)
|
|
646
|
+
except OSError:
|
|
647
|
+
pass
|
|
648
|
+
else:
|
|
649
|
+
# Standard chroot mode
|
|
650
|
+
chroot_cmd = ["chroot"]
|
|
651
|
+
if userspec:
|
|
652
|
+
chroot_cmd.extend(["--userspec", userspec])
|
|
653
|
+
|
|
654
|
+
chroot_cmd.append(str(self.chroot_dir))
|
|
655
|
+
chroot_cmd.extend(command)
|
|
656
|
+
|
|
657
|
+
env = os.environ.copy()
|
|
658
|
+
env["SHELL"] = "/bin/bash"
|
|
659
|
+
|
|
660
|
+
return subprocess.run(chroot_cmd, check=False, env=env, capture_output=capture_output, text=text)
|
|
661
|
+
|
|
662
|
+
def __enter__(self):
|
|
663
|
+
self.setup()
|
|
664
|
+
return self
|
|
665
|
+
|
|
666
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
667
|
+
self.teardown()
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
# Main entry point for command-line usage
|
|
671
|
+
def main():
|
|
672
|
+
"""Command-line interface for chorut."""
|
|
673
|
+
import argparse
|
|
674
|
+
|
|
675
|
+
parser = argparse.ArgumentParser(
|
|
676
|
+
description="Python wrapper of chroot",
|
|
677
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
678
|
+
epilog="""
|
|
679
|
+
If 'command' is unspecified, chorut will launch /bin/bash.
|
|
680
|
+
|
|
681
|
+
Note that when using chorut, the target chroot directory *should* be a
|
|
682
|
+
mountpoint. This ensures that tools such as pacman(8) or findmnt(8) have an
|
|
683
|
+
accurate hierarchy of the mounted filesystems within the chroot.
|
|
684
|
+
|
|
685
|
+
If your chroot target is not a mountpoint, you can bind mount the directory on
|
|
686
|
+
itself to make it a mountpoint, i.e. 'mount --bind /your/chroot /your/chroot'.
|
|
687
|
+
""",
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
parser.add_argument("chroot_dir", help="chroot directory")
|
|
691
|
+
parser.add_argument("command", nargs=argparse.REMAINDER, help="command and arguments to execute")
|
|
692
|
+
parser.add_argument("-N", "--unshare", action="store_true", help="Run in unshare mode as a regular user")
|
|
693
|
+
parser.add_argument(
|
|
694
|
+
"-u", "--userspec", metavar="USER[:GROUP]", help="Specify non-root user and optional group to use"
|
|
695
|
+
)
|
|
696
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose logging")
|
|
697
|
+
parser.add_argument(
|
|
698
|
+
"-m",
|
|
699
|
+
"--mount",
|
|
700
|
+
action="append",
|
|
701
|
+
metavar="SOURCE:TARGET[:OPTIONS]",
|
|
702
|
+
help="Add custom mount (can be used multiple times). Format: source:target[:options]",
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
args = parser.parse_args()
|
|
706
|
+
|
|
707
|
+
if args.verbose:
|
|
708
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
709
|
+
|
|
710
|
+
# Parse custom mounts
|
|
711
|
+
custom_mounts = []
|
|
712
|
+
if args.mount:
|
|
713
|
+
for mount_spec in args.mount:
|
|
714
|
+
parts = mount_spec.split(":")
|
|
715
|
+
if len(parts) < 2:
|
|
716
|
+
print(
|
|
717
|
+
f"Error: Invalid mount specification '{mount_spec}'. Format: source:target[:options]",
|
|
718
|
+
file=sys.stderr,
|
|
719
|
+
)
|
|
720
|
+
return 1
|
|
721
|
+
|
|
722
|
+
mount_dict = {
|
|
723
|
+
"source": parts[0],
|
|
724
|
+
"target": parts[1],
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
# Parse options if provided
|
|
728
|
+
if len(parts) > 2:
|
|
729
|
+
options = parts[2]
|
|
730
|
+
if "bind" in options:
|
|
731
|
+
mount_dict["bind"] = True
|
|
732
|
+
mount_dict["options"] = options
|
|
733
|
+
|
|
734
|
+
custom_mounts.append(mount_dict)
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
with ChrootManager(args.chroot_dir, unshare_mode=args.unshare, custom_mounts=custom_mounts) as chroot:
|
|
738
|
+
result = chroot.execute(args.command if args.command else None, userspec=args.userspec)
|
|
739
|
+
return result.returncode
|
|
740
|
+
except ChrootError as e:
|
|
741
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
742
|
+
return 1
|
|
743
|
+
except KeyboardInterrupt:
|
|
744
|
+
return 130
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
if __name__ == "__main__":
|
|
748
|
+
sys.exit(main())
|
|
749
|
+
|
|
750
|
+
__all__ = [
|
|
751
|
+
"ChrootError",
|
|
752
|
+
"ChrootManager",
|
|
753
|
+
"MountError",
|
|
754
|
+
"MountManager",
|
|
755
|
+
]
|
chorut/__main__.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chorut
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Python implementation of an enhanced chroot functionality with minimal dependencies
|
|
5
|
+
Project-URL: Homepage, https://github.com/abuss/chorut
|
|
6
|
+
Project-URL: Repository, https://github.com/abuss/chorut.git
|
|
7
|
+
Project-URL: Issues, https://github.com/abuss/chorut/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/abuss/chorut#readme
|
|
9
|
+
Author: Antal Buss
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: chroot,containers,linux,namespaces
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: System Administrators
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Operating System
|
|
24
|
+
Classifier: Topic :: System :: Systems Administration
|
|
25
|
+
Classifier: Topic :: Utilities
|
|
26
|
+
Requires-Python: >=3.12
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pre-commit>=4.3.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# chorut
|
|
35
|
+
|
|
36
|
+
A Python library that provides chroot functionality inspired by arch-chroot with minimal dependencies, using only Python standard library modules.
|
|
37
|
+
|
|
38
|
+
## Features
|
|
39
|
+
|
|
40
|
+
- **Complete chroot setup**: Automatically mounts proc, sys, dev, devpts, shm, run, and tmp filesystems
|
|
41
|
+
- **Custom mounts**: Support for user-defined bind mounts and filesystem mounts
|
|
42
|
+
- **Unshare mode**: Support for running as non-root user using Linux namespaces
|
|
43
|
+
- **Context manager**: Clean automatic setup and teardown
|
|
44
|
+
- **resolv.conf handling**: Proper DNS configuration in chroot
|
|
45
|
+
- **String and list commands**: Execute commands using either list format or string format
|
|
46
|
+
- **Output capture**: Capture stdout and stderr from executed commands
|
|
47
|
+
- **Error handling**: Comprehensive error reporting and cleanup
|
|
48
|
+
- **Zero external dependencies**: Uses only Python standard library
|
|
49
|
+
|
|
50
|
+
## Installation
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install chorut
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### As a Library
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from chorut import ChrootManager
|
|
62
|
+
|
|
63
|
+
# Basic usage as root
|
|
64
|
+
with ChrootManager('/path/to/chroot') as chroot:
|
|
65
|
+
result = chroot.execute(['ls', '-la'])
|
|
66
|
+
|
|
67
|
+
# String commands (parsed with shlex.split)
|
|
68
|
+
with ChrootManager('/path/to/chroot') as chroot:
|
|
69
|
+
result = chroot.execute('ls -la /etc')
|
|
70
|
+
|
|
71
|
+
# Output capture
|
|
72
|
+
with ChrootManager('/path/to/chroot') as chroot:
|
|
73
|
+
result = chroot.execute('cat /etc/hostname', capture_output=True)
|
|
74
|
+
if result.returncode == 0:
|
|
75
|
+
hostname = result.stdout.strip()
|
|
76
|
+
print(f"Hostname: {hostname}")
|
|
77
|
+
|
|
78
|
+
# Non-root usage with unshare mode (requires complete chroot environment)
|
|
79
|
+
with ChrootManager('/path/to/complete/chroot', unshare_mode=True) as chroot:
|
|
80
|
+
result = chroot.execute(['whoami'])
|
|
81
|
+
|
|
82
|
+
# Manual setup/teardown
|
|
83
|
+
chroot = ChrootManager('/path/to/chroot')
|
|
84
|
+
chroot.setup()
|
|
85
|
+
try:
|
|
86
|
+
result = chroot.execute(['bash', '-c', 'echo "Hello from chroot"'])
|
|
87
|
+
finally:
|
|
88
|
+
chroot.teardown()
|
|
89
|
+
|
|
90
|
+
# With custom mounts
|
|
91
|
+
custom_mounts = [
|
|
92
|
+
{
|
|
93
|
+
"source": "/home",
|
|
94
|
+
"target": "home",
|
|
95
|
+
"bind": True,
|
|
96
|
+
"options": "ro" # Read-only bind mount
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"source": "tmpfs",
|
|
100
|
+
"target": "workspace",
|
|
101
|
+
"fstype": "tmpfs",
|
|
102
|
+
"options": "size=1G"
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
with ChrootManager('/path/to/chroot', custom_mounts=custom_mounts) as chroot:
|
|
107
|
+
result = chroot.execute(['df', '-h'])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### String Commands and Shell Features
|
|
111
|
+
|
|
112
|
+
The `execute` method accepts both list and string commands:
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# List format (recommended for complex commands)
|
|
116
|
+
result = chroot.execute(['ls', '-la', '/etc'])
|
|
117
|
+
|
|
118
|
+
# String format (parsed with shlex.split or auto-wrapped with bash -c)
|
|
119
|
+
result = chroot.execute('ls -la /etc')
|
|
120
|
+
|
|
121
|
+
# Shell features now work automatically (auto_shell=True by default)
|
|
122
|
+
result = chroot.execute('ls | wc -l') # Pipes
|
|
123
|
+
result = chroot.execute('echo hello && echo world') # Logical operators
|
|
124
|
+
result = chroot.execute('echo `date`') # Command substitution
|
|
125
|
+
result = chroot.execute('ls *.txt') # Glob patterns
|
|
126
|
+
result = chroot.execute('echo $HOME') # Variable expansion
|
|
127
|
+
|
|
128
|
+
# Manual shell invocation still works
|
|
129
|
+
result = chroot.execute("bash -c 'ls | wc -l'")
|
|
130
|
+
|
|
131
|
+
# Disable auto-detection by setting auto_shell=False
|
|
132
|
+
chroot_manual = ChrootManager('/path/to/chroot', auto_shell=False)
|
|
133
|
+
result = chroot_manual.execute("bash -c 'ls | wc -l'") # Explicit bash -c needed
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Auto-Detection**: By default (`auto_shell=True`), string commands are automatically analyzed for shell metacharacters (pipes `|`, logical operators `&&`/`||`, redirects `<>`/`>`, command substitution `` `cmd` ``/`$(cmd)`, glob patterns `*`/`?`, variable expansion `$VAR`, etc.). When detected, the command is automatically wrapped with `bash -c`. Simple commands are still parsed with `shlex.split()` for security.
|
|
137
|
+
|
|
138
|
+
### Output Capture
|
|
139
|
+
|
|
140
|
+
Capture command output using the `capture_output` parameter:
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
# Capture both stdout and stderr
|
|
144
|
+
result = chroot.execute('cat /etc/hostname', capture_output=True)
|
|
145
|
+
if result.returncode == 0:
|
|
146
|
+
hostname = result.stdout.strip()
|
|
147
|
+
|
|
148
|
+
# Capture with error handling
|
|
149
|
+
result = chroot.execute('ls /nonexistent', capture_output=True)
|
|
150
|
+
if result.returncode != 0:
|
|
151
|
+
error_msg = result.stderr.strip()
|
|
152
|
+
|
|
153
|
+
# Get raw bytes instead of text
|
|
154
|
+
result = chroot.execute('cat binary_file', capture_output=True, text=False)
|
|
155
|
+
binary_data = result.stdout
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Custom Mounts
|
|
159
|
+
|
|
160
|
+
You can specify additional mounts to be set up in the chroot environment. Each mount specification is a dictionary with the following keys:
|
|
161
|
+
|
|
162
|
+
- `source` (required): Source path, device, or filesystem type
|
|
163
|
+
- `target` (required): Target path relative to chroot root
|
|
164
|
+
- `fstype` (optional): Filesystem type (e.g., "tmpfs", "ext4")
|
|
165
|
+
- `options` (optional): Mount options (e.g., "ro", "size=1G")
|
|
166
|
+
- `bind` (optional): Whether this is a bind mount (default: False)
|
|
167
|
+
- `mkdir` (optional): Whether to create target directory (default: True)
|
|
168
|
+
|
|
169
|
+
#### Examples:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
# Bind mount home directory as read-only
|
|
173
|
+
{
|
|
174
|
+
"source": "/home",
|
|
175
|
+
"target": "home",
|
|
176
|
+
"bind": True,
|
|
177
|
+
"options": "ro"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Create a tmpfs workspace
|
|
181
|
+
{
|
|
182
|
+
"source": "tmpfs",
|
|
183
|
+
"target": "tmp/workspace",
|
|
184
|
+
"fstype": "tmpfs",
|
|
185
|
+
"options": "size=512M,mode=1777"
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Bind mount a specific directory
|
|
189
|
+
{
|
|
190
|
+
"source": "/var/cache/pacman",
|
|
191
|
+
"target": "var/cache/pacman",
|
|
192
|
+
"bind": True
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Command Line
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# Basic chroot (requires root)
|
|
200
|
+
sudo chorut /path/to/chroot
|
|
201
|
+
|
|
202
|
+
# Run specific command
|
|
203
|
+
sudo chorut /path/to/chroot ls -la
|
|
204
|
+
|
|
205
|
+
# Non-root mode (requires proper chroot environment)
|
|
206
|
+
chorut -N /path/to/complete/chroot
|
|
207
|
+
|
|
208
|
+
# Specify user
|
|
209
|
+
sudo chorut -u user:group /path/to/chroot
|
|
210
|
+
|
|
211
|
+
# Verbose output
|
|
212
|
+
chorut -v -N /path/to/chroot
|
|
213
|
+
|
|
214
|
+
# Custom mounts
|
|
215
|
+
chorut -m "/home:home:bind,ro" -m "tmpfs:workspace:size=1G" /path/to/chroot
|
|
216
|
+
|
|
217
|
+
# Multiple custom mounts
|
|
218
|
+
chorut -N \
|
|
219
|
+
-m "/var/cache:var/cache:bind" \
|
|
220
|
+
-m "tmpfs:tmp/build:size=2G" \
|
|
221
|
+
/path/to/chroot make -j4
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### Command Line Mount Format
|
|
225
|
+
|
|
226
|
+
The `-m/--mount` option accepts mount specifications in the format:
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
SOURCE:TARGET[:OPTIONS]
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
- **SOURCE**: Source path, device, or filesystem type
|
|
233
|
+
- **TARGET**: Target path relative to chroot (without leading slash)
|
|
234
|
+
- **OPTIONS**: Comma-separated mount options (optional)
|
|
235
|
+
|
|
236
|
+
Special options:
|
|
237
|
+
- `bind` - Creates a bind mount
|
|
238
|
+
- Other options are passed to the mount command
|
|
239
|
+
|
|
240
|
+
Examples:
|
|
241
|
+
- `-m "/home:home:bind,ro"` - Read-only bind mount of /home
|
|
242
|
+
- `-m "tmpfs:workspace:size=1G"` - 1GB tmpfs at /workspace
|
|
243
|
+
- `-m "/dev/sdb1:mnt/data:rw"` - Mount device with read-write access
|
|
244
|
+
|
|
245
|
+
### Command Line Options
|
|
246
|
+
|
|
247
|
+
- `-h, --help`: Show help message
|
|
248
|
+
- `-N, --unshare`: Run in unshare mode as regular user
|
|
249
|
+
- `-u USER[:GROUP], --userspec USER[:GROUP]`: Specify user/group to run as
|
|
250
|
+
- `-v, --verbose`: Enable verbose logging
|
|
251
|
+
- `-m SOURCE:TARGET[:OPTIONS], --mount SOURCE:TARGET[:OPTIONS]`: Add custom mount (can be used multiple times)
|
|
252
|
+
|
|
253
|
+
## API Reference
|
|
254
|
+
|
|
255
|
+
### ChrootManager
|
|
256
|
+
|
|
257
|
+
The main class for managing chroot environments.
|
|
258
|
+
|
|
259
|
+
#### Constructor
|
|
260
|
+
|
|
261
|
+
```python
|
|
262
|
+
ChrootManager(chroot_dir, unshare_mode=False, custom_mounts=None, auto_shell=True)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
- `chroot_dir`: Path to the chroot directory
|
|
266
|
+
- `unshare_mode`: Whether to use unshare mode for non-root operation
|
|
267
|
+
- `custom_mounts`: Optional list of custom mount specifications
|
|
268
|
+
- `auto_shell`: Whether to automatically detect shell features in string commands and wrap them with 'bash -c' (default: True)
|
|
269
|
+
|
|
270
|
+
#### Methods
|
|
271
|
+
|
|
272
|
+
- `setup()`: Set up the chroot environment
|
|
273
|
+
- `teardown()`: Clean up the chroot environment
|
|
274
|
+
- `execute(command=None, userspec=None, capture_output=False, text=True)`: Execute a command in the chroot
|
|
275
|
+
|
|
276
|
+
##### execute() Parameters
|
|
277
|
+
|
|
278
|
+
- `command`: Command to execute. Can be:
|
|
279
|
+
- `list[str]`: List of command and arguments (e.g., `['ls', '-la']`)
|
|
280
|
+
- `str`: String command parsed with `shlex.split()` (e.g., `'ls -la'`)
|
|
281
|
+
- `None`: Start interactive shell
|
|
282
|
+
- `userspec`: User specification in format "user" or "user:group"
|
|
283
|
+
- `capture_output`: If `True`, capture stdout and stderr (default: `False`)
|
|
284
|
+
- `text`: If `True`, decode output as text; if `False`, return bytes (default: `True`)
|
|
285
|
+
|
|
286
|
+
##### execute() Return Value
|
|
287
|
+
|
|
288
|
+
Returns a `subprocess.CompletedProcess` object with:
|
|
289
|
+
- `returncode`: Exit code of the command
|
|
290
|
+
- `stdout`: Command output (if `capture_output=True`)
|
|
291
|
+
- `stderr`: Command error output (if `capture_output=True`)
|
|
292
|
+
|
|
293
|
+
##### execute() Examples
|
|
294
|
+
|
|
295
|
+
```python
|
|
296
|
+
# List command
|
|
297
|
+
result = chroot.execute(['ls', '-la'])
|
|
298
|
+
|
|
299
|
+
# String command
|
|
300
|
+
result = chroot.execute('ls -la')
|
|
301
|
+
|
|
302
|
+
# With output capture
|
|
303
|
+
result = chroot.execute('cat /etc/hostname', capture_output=True)
|
|
304
|
+
hostname = result.stdout.strip()
|
|
305
|
+
|
|
306
|
+
# Shell features require explicit bash
|
|
307
|
+
result = chroot.execute("bash -c 'ls | wc -l'", capture_output=True)
|
|
308
|
+
line_count = int(result.stdout.strip())
|
|
309
|
+
|
|
310
|
+
# Interactive shell (command=None)
|
|
311
|
+
chroot.execute() # Starts bash shell
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Exceptions
|
|
315
|
+
|
|
316
|
+
- `ChrootError`: Raised for chroot-related errors
|
|
317
|
+
- `MountError`: Raised for mount-related errors
|
|
318
|
+
|
|
319
|
+
## Requirements
|
|
320
|
+
|
|
321
|
+
- Python 3.12+
|
|
322
|
+
- Linux system with mount/umount utilities
|
|
323
|
+
- Root privileges (unless using unshare mode)
|
|
324
|
+
|
|
325
|
+
### Unshare Mode Requirements
|
|
326
|
+
|
|
327
|
+
When using unshare mode (`-N` flag), the following additional requirements apply:
|
|
328
|
+
|
|
329
|
+
- `unshare` command must be available
|
|
330
|
+
- The chroot directory must contain a complete filesystem with:
|
|
331
|
+
- Essential binaries in `/bin`, `/usr/bin`, etc.
|
|
332
|
+
- Required libraries in `/lib`, `/lib64`, `/usr/lib`, etc.
|
|
333
|
+
- Proper directory structure (`/etc`, `/proc`, `/sys`, `/dev`, etc.)
|
|
334
|
+
|
|
335
|
+
**Note**: Unshare mode performs all mount operations within an unshared mount namespace, allowing non-root users to create chroot environments. However, the target directory must still contain a complete, functional filesystem for the chroot to work properly.
|
|
336
|
+
|
|
337
|
+
For example, trying to chroot into `/tmp` will fail because it lacks the necessary binaries and libraries. You need a proper root filesystem (like those created by `debootstrap`, `pacstrap`, or similar tools).
|
|
338
|
+
|
|
339
|
+
## License
|
|
340
|
+
|
|
341
|
+
This project is in the public domain.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
chorut/__init__.py,sha256=GuW65RuZ4nNfTtPR2qsZrI8x6oh2b-Wava-HblY9CM8,28456
|
|
2
|
+
chorut/__main__.py,sha256=46kvAS_PdJmqfXrdOFTDkltJTLKrPiejmdzRaJkfo0o,136
|
|
3
|
+
chorut-0.1.4.dist-info/METADATA,sha256=E7zWMxAGowKSJ4_1OaOfgbvEJREa-eM8mDAsvwDyPas,11250
|
|
4
|
+
chorut-0.1.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
5
|
+
chorut-0.1.4.dist-info/entry_points.txt,sha256=pUIFs66Yfmu0VbGhoMoE-zT2bpgcsajJOfSBFZJP_kg,39
|
|
6
|
+
chorut-0.1.4.dist-info/licenses/LICENSE,sha256=HVoHBV1bIq-CJvSUPfgkQCQV6XeecLSmbgOlDduOD5A,1070
|
|
7
|
+
chorut-0.1.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Antal A. Buss
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|