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 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,9 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Command-line interface for chorut.
4
+ """
5
+
6
+ from chorut import main
7
+
8
+ if __name__ == "__main__":
9
+ exit(main())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ chorut = chorut:main
@@ -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.