coldrun 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.
- coldrun/__init__.py +12 -0
- coldrun/cli.py +53 -0
- coldrun/config.py +29 -0
- coldrun/detect.py +223 -0
- coldrun/doctor.py +208 -0
- coldrun/fetch/__init__.py +82 -0
- coldrun/fetch/base.py +137 -0
- coldrun/fetch/local.py +46 -0
- coldrun/fetch/url.py +136 -0
- coldrun/pipeline.py +1 -0
- coldrun/py.typed +0 -0
- coldrun/report.py +195 -0
- coldrun/runners/__init__.py +15 -0
- coldrun/runners/base.py +97 -0
- coldrun/runners/registry.py +125 -0
- coldrun/sandbox/__init__.py +29 -0
- coldrun/sandbox/base.py +219 -0
- coldrun/sandbox/bwrap.py +226 -0
- coldrun/vet/__init__.py +1 -0
- coldrun/vet/findings.py +66 -0
- coldrun-0.1.0.dist-info/METADATA +53 -0
- coldrun-0.1.0.dist-info/RECORD +25 -0
- coldrun-0.1.0.dist-info/WHEEL +4 -0
- coldrun-0.1.0.dist-info/entry_points.txt +2 -0
- coldrun-0.1.0.dist-info/licenses/LICENSE +21 -0
coldrun/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""coldrun: fetch, vet and sandbox any downloaded script in one command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("coldrun")
|
|
9
|
+
except PackageNotFoundError: # pragma: no cover - only when run from a bare checkout
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
coldrun/cli.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Typer application for the ``coldrun`` command.
|
|
2
|
+
|
|
3
|
+
This module wires command-line options to the pipeline and holds no logic of
|
|
4
|
+
its own. Commands are added here as the milestones land.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from coldrun import __version__
|
|
13
|
+
from coldrun import doctor as doctor_mod
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="coldrun",
|
|
17
|
+
help="Fetch, vet and sandbox any downloaded script in one command.",
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
add_completion=False,
|
|
20
|
+
rich_markup_mode="rich",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _version_callback(value: bool) -> None:
|
|
25
|
+
if value:
|
|
26
|
+
typer.echo(f"coldrun {__version__}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.callback()
|
|
31
|
+
def main(
|
|
32
|
+
version: bool = typer.Option(
|
|
33
|
+
False,
|
|
34
|
+
"--version",
|
|
35
|
+
"-V",
|
|
36
|
+
help="Show the version and exit.",
|
|
37
|
+
callback=_version_callback,
|
|
38
|
+
is_eager=True,
|
|
39
|
+
),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Fetch, vet and sandbox any downloaded script in one command."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def doctor() -> None:
|
|
46
|
+
"""Check bwrap, podman, uv and kernel support, and say how to fix what is missing."""
|
|
47
|
+
results = doctor_mod.run_checks()
|
|
48
|
+
doctor_mod.render(results, Console(highlight=False))
|
|
49
|
+
raise typer.Exit(code=1 if doctor_mod.has_failures(results) else 0)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__": # pragma: no cover
|
|
53
|
+
app()
|
coldrun/config.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Where coldrun keeps its files on the host.
|
|
2
|
+
|
|
3
|
+
Only paths live here for now; the TOML config file arrives when there are
|
|
4
|
+
settings worth persisting. Both directories honour the XDG variables.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
APP = "coldrun"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def config_dir() -> Path:
|
|
16
|
+
"""``$XDG_CONFIG_HOME/coldrun`` or ``~/.config/coldrun``."""
|
|
17
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
18
|
+
return Path(base) / APP
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def cache_dir() -> Path:
|
|
22
|
+
"""``$XDG_CACHE_HOME/coldrun`` or ``~/.cache/coldrun``."""
|
|
23
|
+
base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache")
|
|
24
|
+
return Path(base) / APP
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def quarantine_root() -> Path:
|
|
28
|
+
"""Parent of every quarantine directory, under the cache."""
|
|
29
|
+
return cache_dir() / "quarantine"
|
coldrun/detect.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Pick a runner from shebang, extension and file magic.
|
|
2
|
+
|
|
3
|
+
The language knowledge lives in the runners: each one scores a file through
|
|
4
|
+
``Runner.detect``. This module supplies the cheap, shared signals runners
|
|
5
|
+
build on, asks every registered runner, and turns the scores into one choice
|
|
6
|
+
or one clear error. Only bytes are inspected; nothing is executed or imported.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shlex
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from enum import Enum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from coldrun.runners.base import Runner
|
|
19
|
+
from coldrun.runners.registry import Registry
|
|
20
|
+
|
|
21
|
+
#: A runner must be at least this sure before coldrun runs anything.
|
|
22
|
+
THRESHOLD = 0.5
|
|
23
|
+
_SNIFF_BYTES = 512
|
|
24
|
+
_SHEBANG_MAX = 256
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FileKind(str, Enum):
|
|
28
|
+
"""Coarse shape of a file, from its first bytes."""
|
|
29
|
+
|
|
30
|
+
SCRIPT = "script" # has a shebang
|
|
31
|
+
TEXT = "text" # readable text without a shebang
|
|
32
|
+
ELF = "elf"
|
|
33
|
+
PE = "pe"
|
|
34
|
+
MACHO = "macho"
|
|
35
|
+
ZIP = "zip"
|
|
36
|
+
GZIP = "gzip"
|
|
37
|
+
XZ = "xz"
|
|
38
|
+
BZIP2 = "bzip2"
|
|
39
|
+
ZSTD = "zstd"
|
|
40
|
+
BINARY = "binary" # other non-text
|
|
41
|
+
EMPTY = "empty"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_MAGIC: tuple[tuple[bytes, FileKind], ...] = (
|
|
45
|
+
(b"\x7fELF", FileKind.ELF),
|
|
46
|
+
(b"MZ", FileKind.PE),
|
|
47
|
+
(b"\xcf\xfa\xed\xfe", FileKind.MACHO),
|
|
48
|
+
(b"\xfe\xed\xfa\xcf", FileKind.MACHO),
|
|
49
|
+
(b"\xca\xfe\xba\xbe", FileKind.MACHO),
|
|
50
|
+
(b"PK\x03\x04", FileKind.ZIP),
|
|
51
|
+
(b"\x1f\x8b", FileKind.GZIP),
|
|
52
|
+
(b"\xfd7zXZ\x00", FileKind.XZ),
|
|
53
|
+
(b"BZh", FileKind.BZIP2),
|
|
54
|
+
(b"\x28\xb5\x2f\xfd", FileKind.ZSTD),
|
|
55
|
+
)
|
|
56
|
+
_TEXT_BYTES = bytes(range(0x20, 0x7F)) + b"\t\n\r\x0b\x0c\x1b"
|
|
57
|
+
_ASCII_LIMIT = 0x80
|
|
58
|
+
_BINARY_RATIO = 0.1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DetectError(Exception):
|
|
62
|
+
"""No runner could be chosen.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
what: Why, in one sentence.
|
|
66
|
+
hint: What the user can do, usually ``--lang``.
|
|
67
|
+
candidates: Runner names and scores that were considered.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, what: str, hint: str, candidates: list[tuple[str, float]]) -> None:
|
|
71
|
+
"""Keep the parts separate for the report."""
|
|
72
|
+
super().__init__(f"{what} {hint}")
|
|
73
|
+
self.what = what
|
|
74
|
+
self.hint = hint
|
|
75
|
+
self.candidates = candidates
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Signals:
|
|
80
|
+
"""The shared evidence about one file."""
|
|
81
|
+
|
|
82
|
+
kind: FileKind
|
|
83
|
+
shebang: str | None
|
|
84
|
+
interpreter: str | None
|
|
85
|
+
extension: str
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def is_text(self) -> bool:
|
|
89
|
+
"""True for scripts and plain text."""
|
|
90
|
+
return self.kind in {FileKind.SCRIPT, FileKind.TEXT}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(frozen=True, slots=True)
|
|
94
|
+
class Detection:
|
|
95
|
+
"""The chosen runner and why."""
|
|
96
|
+
|
|
97
|
+
runner: Runner
|
|
98
|
+
confidence: float
|
|
99
|
+
signals: Signals
|
|
100
|
+
forced: bool = False
|
|
101
|
+
candidates: list[tuple[str, float]] = field(default_factory=list)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def reasons(self) -> list[str]:
|
|
105
|
+
"""Human-readable evidence for the report."""
|
|
106
|
+
out: list[str] = []
|
|
107
|
+
if self.forced:
|
|
108
|
+
out.append(f"--lang {self.runner.name}")
|
|
109
|
+
if self.signals.shebang:
|
|
110
|
+
out.append(f"shebang {self.signals.shebang}")
|
|
111
|
+
if self.signals.extension:
|
|
112
|
+
out.append(f"extension {self.signals.extension}")
|
|
113
|
+
if not self.signals.is_text:
|
|
114
|
+
out.append(f"magic {self.signals.kind.value}")
|
|
115
|
+
return out
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def sniff_kind(path: Path) -> FileKind:
|
|
119
|
+
"""Classify a file from its first bytes."""
|
|
120
|
+
with path.open("rb") as fh:
|
|
121
|
+
head = fh.read(_SNIFF_BYTES)
|
|
122
|
+
if not head:
|
|
123
|
+
return FileKind.EMPTY
|
|
124
|
+
for magic, kind in _MAGIC:
|
|
125
|
+
if head.startswith(magic):
|
|
126
|
+
return kind
|
|
127
|
+
if head.startswith(b"#!"):
|
|
128
|
+
return FileKind.SCRIPT
|
|
129
|
+
if b"\x00" in head:
|
|
130
|
+
return FileKind.BINARY
|
|
131
|
+
sample = head.translate(None, _TEXT_BYTES)
|
|
132
|
+
# Allow UTF-8 multibyte sequences; anything with lots of control bytes is binary.
|
|
133
|
+
control = sum(1 for b in sample if b < _ASCII_LIMIT)
|
|
134
|
+
return FileKind.BINARY if control > len(head) * _BINARY_RATIO else FileKind.TEXT
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_shebang(path: Path) -> str | None:
|
|
138
|
+
"""Return the shebang line without ``#!`` and trailing whitespace, or ``None``."""
|
|
139
|
+
with path.open("rb") as fh:
|
|
140
|
+
head = fh.read(_SHEBANG_MAX)
|
|
141
|
+
if not head.startswith(b"#!"):
|
|
142
|
+
return None
|
|
143
|
+
line = head[2:].split(b"\n", 1)[0].strip()
|
|
144
|
+
try:
|
|
145
|
+
return line.decode("utf-8")
|
|
146
|
+
except UnicodeDecodeError:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def interpreter_from_shebang(shebang: str) -> str | None:
|
|
151
|
+
"""Name of the interpreter a shebang asks for, such as ``python3`` or ``bash``.
|
|
152
|
+
|
|
153
|
+
Handles ``/usr/bin/env name``, ``/usr/bin/env -S name args`` and plain
|
|
154
|
+
``/bin/name``. Returns ``None`` for an unparsable line.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
parts = shlex.split(shebang)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return None
|
|
160
|
+
if not parts:
|
|
161
|
+
return None
|
|
162
|
+
if Path(parts[0]).name == "env":
|
|
163
|
+
rest = [p for p in parts[1:] if not p.startswith("-") and "=" not in p]
|
|
164
|
+
return Path(rest[0]).name if rest else None
|
|
165
|
+
return Path(parts[0]).name
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def signals_for(path: Path) -> Signals:
|
|
169
|
+
"""Gather the shared evidence for ``path``."""
|
|
170
|
+
kind = sniff_kind(path)
|
|
171
|
+
shebang = read_shebang(path) if kind == FileKind.SCRIPT else None
|
|
172
|
+
return Signals(
|
|
173
|
+
kind=kind,
|
|
174
|
+
shebang=shebang,
|
|
175
|
+
interpreter=interpreter_from_shebang(shebang) if shebang else None,
|
|
176
|
+
extension=path.suffix.lower(),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def detect(path: Path, registry: Registry, lang: str | None = None) -> Detection:
|
|
181
|
+
"""Choose the runner for ``path``.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
path: The quarantined file.
|
|
185
|
+
registry: Where runners come from.
|
|
186
|
+
lang: ``--lang`` override; skips scoring and takes that runner.
|
|
187
|
+
|
|
188
|
+
Raises:
|
|
189
|
+
DetectError: When nothing is confident enough, or ``lang`` is unknown.
|
|
190
|
+
"""
|
|
191
|
+
signals = signals_for(path)
|
|
192
|
+
if lang is not None:
|
|
193
|
+
try:
|
|
194
|
+
runner = registry.get(lang)
|
|
195
|
+
except LookupError as exc:
|
|
196
|
+
raise DetectError(
|
|
197
|
+
str(exc) + ".",
|
|
198
|
+
"Pick one of the available runners with --lang.",
|
|
199
|
+
[],
|
|
200
|
+
) from exc
|
|
201
|
+
return Detection(runner=runner, confidence=1.0, signals=signals, forced=True)
|
|
202
|
+
|
|
203
|
+
scored: list[tuple[float, Runner]] = []
|
|
204
|
+
for runner in registry.all():
|
|
205
|
+
score = float(runner.detect(path))
|
|
206
|
+
scored.append((min(max(score, 0.0), 1.0), runner))
|
|
207
|
+
scored.sort(key=lambda s: s[0], reverse=True)
|
|
208
|
+
candidates = [(r.name, s) for s, r in scored]
|
|
209
|
+
if not scored or scored[0][0] < THRESHOLD:
|
|
210
|
+
what = f"Could not tell what kind of script {path.name} is ({signals.kind.value}"
|
|
211
|
+
what += f", shebang {signals.shebang}" if signals.shebang else ""
|
|
212
|
+
what += ")."
|
|
213
|
+
names = ", ".join(registry.names()) or "none installed"
|
|
214
|
+
raise DetectError(what, f"Force a runner with --lang NAME. Available: {names}.", candidates)
|
|
215
|
+
best, runner = scored[0]
|
|
216
|
+
if len(scored) > 1 and scored[1][0] == best:
|
|
217
|
+
tied = ", ".join(r.name for s, r in scored if s == best)
|
|
218
|
+
raise DetectError(
|
|
219
|
+
f"{path.name} matches more than one runner equally ({tied}).",
|
|
220
|
+
"Choose one with --lang NAME.",
|
|
221
|
+
candidates,
|
|
222
|
+
)
|
|
223
|
+
return Detection(runner=runner, confidence=best, signals=signals, candidates=candidates)
|
coldrun/doctor.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""``coldrun doctor``: check bwrap, podman, uv and kernel support.
|
|
2
|
+
|
|
3
|
+
Each check is a small function that returns a :class:`CheckResult` with the
|
|
4
|
+
fix for its failure, so the output tells the user exactly what to run next.
|
|
5
|
+
The probes are injectable for tests.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import platform
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import Enum
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
from rich.text import Text
|
|
20
|
+
|
|
21
|
+
from coldrun.config import cache_dir
|
|
22
|
+
from coldrun.sandbox import bwrap as bw
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
|
|
30
|
+
MIN_PYTHON = (3, 10)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Status(str, Enum):
|
|
34
|
+
"""Outcome of one check."""
|
|
35
|
+
|
|
36
|
+
PASS = "pass" # noqa: S105 - a status, not a password
|
|
37
|
+
WARN = "warn"
|
|
38
|
+
FAIL = "fail"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class CheckResult:
|
|
43
|
+
"""One line of doctor output."""
|
|
44
|
+
|
|
45
|
+
name: str
|
|
46
|
+
status: Status
|
|
47
|
+
detail: str
|
|
48
|
+
fix: str = ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _run_version(argv: list[str]) -> str | None:
|
|
52
|
+
try:
|
|
53
|
+
out = subprocess.run( # noqa: S603 - fixed argv of a known tool
|
|
54
|
+
argv, capture_output=True, text=True, check=False, timeout=10
|
|
55
|
+
)
|
|
56
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
57
|
+
return None
|
|
58
|
+
line = (out.stdout or out.stderr).strip().splitlines()
|
|
59
|
+
return line[0] if line else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class Probes:
|
|
64
|
+
"""Host lookups the checks depend on; tests replace them."""
|
|
65
|
+
|
|
66
|
+
which: Callable[[str], str | None] = shutil.which
|
|
67
|
+
version_of: Callable[[list[str]], str | None] = _run_version
|
|
68
|
+
userns_problem: Callable[[], tuple[str, str] | None] = bw.userns_problem
|
|
69
|
+
tiocsti_blocked: Callable[[], bool] = bw.tiocsti_is_blocked
|
|
70
|
+
python_version: tuple[int, int] = sys.version_info[:2]
|
|
71
|
+
kernel: str = platform.release()
|
|
72
|
+
cache: Path | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def check_bwrap(p: Probes) -> CheckResult:
|
|
76
|
+
"""Bubblewrap is installed."""
|
|
77
|
+
path = p.which("bwrap")
|
|
78
|
+
if path is None:
|
|
79
|
+
return CheckResult(
|
|
80
|
+
"bubblewrap",
|
|
81
|
+
Status.FAIL,
|
|
82
|
+
"not installed",
|
|
83
|
+
"sudo pacman -S bubblewrap (Arch) or sudo apt install bubblewrap (Debian, Kali)",
|
|
84
|
+
)
|
|
85
|
+
ver = p.version_of([path, "--version"]) or "version unknown"
|
|
86
|
+
return CheckResult("bubblewrap", Status.PASS, f"{path} ({ver})")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_userns(p: Probes) -> CheckResult:
|
|
90
|
+
"""Unprivileged user namespaces are allowed."""
|
|
91
|
+
problem = p.userns_problem()
|
|
92
|
+
if problem is None:
|
|
93
|
+
return CheckResult("user namespaces", Status.PASS, "unprivileged user namespaces allowed")
|
|
94
|
+
return CheckResult("user namespaces", Status.FAIL, problem[0], problem[1])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_tiocsti(p: Probes) -> CheckResult:
|
|
98
|
+
"""The kernel blocks TIOCSTI so interactive scripts can keep the terminal."""
|
|
99
|
+
if p.tiocsti_blocked():
|
|
100
|
+
return CheckResult("TIOCSTI", Status.PASS, "blocked by the kernel; TTY passthrough is safe")
|
|
101
|
+
return CheckResult(
|
|
102
|
+
"TIOCSTI",
|
|
103
|
+
Status.WARN,
|
|
104
|
+
"legacy TIOCSTI allowed; interactive scripts run in a new session (no job control)",
|
|
105
|
+
"sudo sysctl -w dev.tty.legacy_tiocsti=0 (kernel 6.2 or newer)",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_podman(p: Probes) -> CheckResult:
|
|
110
|
+
"""Podman is optional until a runner needs a full image."""
|
|
111
|
+
path = p.which("podman")
|
|
112
|
+
if path is None:
|
|
113
|
+
return CheckResult(
|
|
114
|
+
"podman",
|
|
115
|
+
Status.WARN,
|
|
116
|
+
"not installed; only needed for Python 2, Node, Go and Ruby runners (M5)",
|
|
117
|
+
"sudo pacman -S podman or sudo apt install podman",
|
|
118
|
+
)
|
|
119
|
+
ver = p.version_of([path, "--version"]) or "version unknown"
|
|
120
|
+
return CheckResult("podman", Status.PASS, f"{path} ({ver})")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def check_uv(p: Probes) -> CheckResult:
|
|
124
|
+
"""Uv installs Python dependencies inside the sandbox."""
|
|
125
|
+
path = p.which("uv")
|
|
126
|
+
if path is None:
|
|
127
|
+
return CheckResult(
|
|
128
|
+
"uv",
|
|
129
|
+
Status.WARN,
|
|
130
|
+
"not installed; needed by the Python runner (M2)",
|
|
131
|
+
"curl -LsSf https://astral.sh/uv/install.sh | sh or sudo pacman -S uv",
|
|
132
|
+
)
|
|
133
|
+
ver = p.version_of([path, "--version"]) or "version unknown"
|
|
134
|
+
return CheckResult("uv", Status.PASS, f"{path} ({ver})")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def check_python(p: Probes) -> CheckResult:
|
|
138
|
+
"""Python is new enough for coldrun itself."""
|
|
139
|
+
ver = ".".join(map(str, p.python_version))
|
|
140
|
+
if p.python_version < MIN_PYTHON:
|
|
141
|
+
return CheckResult(
|
|
142
|
+
"python", Status.FAIL, f"{ver} is too old", "coldrun needs Python 3.10 or newer"
|
|
143
|
+
)
|
|
144
|
+
return CheckResult("python", Status.PASS, f"{ver} ({p.kernel})")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def check_cache(p: Probes) -> CheckResult:
|
|
148
|
+
"""The cache directory can be created and written."""
|
|
149
|
+
path = p.cache if p.cache is not None else cache_dir()
|
|
150
|
+
try:
|
|
151
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
probe = path / ".doctor-write-test"
|
|
153
|
+
probe.write_text("ok")
|
|
154
|
+
probe.unlink()
|
|
155
|
+
except OSError as exc:
|
|
156
|
+
return CheckResult(
|
|
157
|
+
"cache", Status.FAIL, f"{path}: {exc.strerror}", f"chown or chmod {path}"
|
|
158
|
+
)
|
|
159
|
+
return CheckResult("cache", Status.PASS, str(path))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
CHECKS: tuple[Callable[[Probes], CheckResult], ...] = (
|
|
163
|
+
check_python,
|
|
164
|
+
check_bwrap,
|
|
165
|
+
check_userns,
|
|
166
|
+
check_tiocsti,
|
|
167
|
+
check_podman,
|
|
168
|
+
check_uv,
|
|
169
|
+
check_cache,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def run_checks(probes: Probes | None = None) -> list[CheckResult]:
|
|
174
|
+
"""Run every check and return the results in display order."""
|
|
175
|
+
p = probes or Probes()
|
|
176
|
+
return [check(p) for check in CHECKS]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def has_failures(results: list[CheckResult]) -> bool:
|
|
180
|
+
"""True when at least one check failed outright."""
|
|
181
|
+
return any(r.status is Status.FAIL for r in results)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
_STYLE = {Status.PASS: "green", Status.WARN: "yellow", Status.FAIL: "bold red"}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render(results: list[CheckResult], console: Console) -> None:
|
|
188
|
+
"""Print the doctor table."""
|
|
189
|
+
table = Table(show_header=True, header_style="bold", box=None, pad_edge=False, padding=(0, 1))
|
|
190
|
+
table.add_column("check", no_wrap=True)
|
|
191
|
+
table.add_column("status", no_wrap=True)
|
|
192
|
+
table.add_column("detail")
|
|
193
|
+
for r in results:
|
|
194
|
+
detail = Text(r.detail)
|
|
195
|
+
if r.fix:
|
|
196
|
+
detail.append("\nfix: ", style="bold")
|
|
197
|
+
detail.append(r.fix)
|
|
198
|
+
table.add_row(r.name, Text(r.status.value, style=_STYLE[r.status]), detail)
|
|
199
|
+
console.print(table)
|
|
200
|
+
if has_failures(results):
|
|
201
|
+
console.print(
|
|
202
|
+
Text(
|
|
203
|
+
"coldrun cannot sandbox scripts until the failures above are fixed.",
|
|
204
|
+
style="bold red",
|
|
205
|
+
)
|
|
206
|
+
)
|
|
207
|
+
else:
|
|
208
|
+
console.print(Text("ready to run.", style="green"))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Fetchers that place a source into a quarantine directory without executing it.
|
|
2
|
+
|
|
3
|
+
:func:`fetch` is the only entry point the pipeline uses. It picks the first
|
|
4
|
+
fetcher whose :meth:`Fetcher.matches` accepts the spec, gives it a fresh
|
|
5
|
+
quarantine directory, then seals the result with a hash and ``source.json``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import urllib.parse
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from coldrun.config import quarantine_root
|
|
15
|
+
from coldrun.fetch.base import (
|
|
16
|
+
MAX_SOURCE_BYTES,
|
|
17
|
+
Fetched,
|
|
18
|
+
Fetcher,
|
|
19
|
+
FetchError,
|
|
20
|
+
new_quarantine,
|
|
21
|
+
seal,
|
|
22
|
+
)
|
|
23
|
+
from coldrun.fetch.local import LocalFetcher
|
|
24
|
+
from coldrun.fetch.url import UrlFetcher
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"MAX_SOURCE_BYTES",
|
|
32
|
+
"FetchError",
|
|
33
|
+
"Fetched",
|
|
34
|
+
"Fetcher",
|
|
35
|
+
"LocalFetcher",
|
|
36
|
+
"UrlFetcher",
|
|
37
|
+
"default_fetchers",
|
|
38
|
+
"fetch",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_fetchers() -> list[Fetcher]:
|
|
43
|
+
"""Fetchers in the order they are tried. URL forms first, paths last."""
|
|
44
|
+
return [UrlFetcher(), LocalFetcher()]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pick(spec: str, fetchers: Sequence[Fetcher]) -> Fetcher:
|
|
48
|
+
for f in fetchers:
|
|
49
|
+
if f.matches(spec):
|
|
50
|
+
return f
|
|
51
|
+
scheme = urllib.parse.urlsplit(spec).scheme or "that"
|
|
52
|
+
raise FetchError(
|
|
53
|
+
f"No fetcher understands {spec!r} ({scheme} is not supported).",
|
|
54
|
+
"Use a local path, an http(s) URL, or a GitHub blob URL.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def fetch(
|
|
59
|
+
spec: str, root: Path | None = None, fetchers: Sequence[Fetcher] | None = None
|
|
60
|
+
) -> Fetched:
|
|
61
|
+
"""Fetch ``spec`` into a new quarantine directory.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
spec: Local path or URL as the user typed it.
|
|
65
|
+
root: Parent for quarantine directories; defaults to the cache.
|
|
66
|
+
fetchers: Override the fetcher list; tests use this.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The sealed :class:`Fetched` record.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
FetchError: With what failed and what to try next. The quarantine
|
|
73
|
+
directory is removed on failure.
|
|
74
|
+
"""
|
|
75
|
+
fetcher = _pick(spec, fetchers if fetchers is not None else default_fetchers())
|
|
76
|
+
qdir = new_quarantine(root if root is not None else quarantine_root())
|
|
77
|
+
try:
|
|
78
|
+
file = fetcher.fetch(spec, qdir)
|
|
79
|
+
return seal(fetcher.kind, spec, file)
|
|
80
|
+
except BaseException:
|
|
81
|
+
shutil.rmtree(qdir, ignore_errors=True)
|
|
82
|
+
raise
|