File size: 6,054 Bytes
b4f2238
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""Module for subprocess runner."""

from __future__ import annotations

import subprocess
from dataclasses import dataclass
from typing import Any

from apps.core.exceptions import ExternalServiceError
from apps.core.security.scrub import scrub_text


@dataclass(frozen=True)
class SubprocessOutput:
    stdout: str
    stderr: str
    returncode: int


class SubprocessRunner:
    def __init__(self, *, allowed_programs: set[str] | None = None, max_output_chars: int = 20_000) -> None:
        self._allowed_programs = allowed_programs
        self._max_output_chars = int(max_output_chars) if max_output_chars is not None else 20_000

    def _truncate(self, value: str) -> str:
        if not value:
            return ""
        if self._max_output_chars <= 0:
            return ""
        if len(value) <= self._max_output_chars:
            return value
        return value[: self._max_output_chars] + "...(truncated)"

    def _validate_args(self, args: list[str]) -> None:
        if not isinstance(args, list) or not args or not all(isinstance(a, str) for a in args):
            raise ExternalServiceError(message="外部命令参数无效", code="SUBPROCESS_INVALID_ARGS", errors={})
        program = str(args[0] or "").strip()
        if not program:
            raise ExternalServiceError(message="外部命令参数无效", code="SUBPROCESS_INVALID_ARGS", errors={})
        if self._allowed_programs is not None and program not in self._allowed_programs:
            raise ExternalServiceError(
                message="外部命令不允许",
                code="SUBPROCESS_NOT_ALLOWED",
                errors={"command": scrub_text(program)},
            )

    def run(
        self,
        *,
        args: list[str],
        timeout_seconds: float | None = None,
        check: bool = False,
        text: bool = True,
    ) -> SubprocessOutput:
        self._validate_args(args)
        try:
            cp = subprocess.run(  # 安全:args 已通过 _validate_args 白名单校验,shell=False
                args,
                check=check,
                capture_output=True,
                text=text,
                timeout=timeout_seconds,
            )
        except subprocess.CalledProcessError as e:
            stdout = e.stdout or ""
            stderr = e.stderr or ""
            if isinstance(stdout, bytes):
                stdout = stdout.decode("utf-8", errors="replace")
            if isinstance(stderr, bytes):
                stderr = stderr.decode("utf-8", errors="replace")
            raise ExternalServiceError(
                message="外部命令执行失败",
                code="SUBPROCESS_NONZERO_EXIT",
                errors={
                    "command": scrub_text(str(args[0] if args else "")),
                    "returncode": int(getattr(e, "returncode", 0) or 0),
                    "stdout": self._truncate(scrub_text(str(stdout))),
                    "stderr": self._truncate(scrub_text(str(stderr))),
                },
            ) from e
        except subprocess.TimeoutExpired as e:
            raise ExternalServiceError(
                message="外部命令执行超时",
                code="SUBPROCESS_TIMEOUT",
                errors={
                    "command": scrub_text(str(args[0] if args else "")),
                    "timeout_seconds": timeout_seconds,
                    "detail": self._truncate(scrub_text(str(e))),
                },
            ) from e
        except FileNotFoundError as e:
            raise ExternalServiceError(
                message="外部命令不存在",
                code="SUBPROCESS_NOT_FOUND",
                errors={
                    "command": scrub_text(str(args[0] if args else "")),
                    "detail": self._truncate(scrub_text(str(e))),
                },
            ) from e
        except Exception as e:
            raise ExternalServiceError(
                message="外部命令执行失败",
                code="SUBPROCESS_FAILED",
                errors={
                    "command": scrub_text(str(args[0] if args else "")),
                    "detail": self._truncate(scrub_text(str(e))),
                },
            ) from e

        stdout = cp.stdout or ""
        stderr = cp.stderr or ""
        if isinstance(stdout, bytes):
            stdout = stdout.decode("utf-8", errors="replace")
        if isinstance(stderr, bytes):
            stderr = stderr.decode("utf-8", errors="replace")
        return SubprocessOutput(
            stdout=self._truncate(scrub_text(str(stdout))),
            stderr=self._truncate(scrub_text(str(stderr))),
            returncode=int(cp.returncode or 0),
        )

    def popen(self, *, args: list[str], **kwargs: Any) -> subprocess.Popen[Any]:
        self._validate_args(args)
        if "shell" in kwargs and kwargs.get("shell"):
            raise ExternalServiceError(
                message="外部命令执行参数不安全",
                code="SUBPROCESS_UNSAFE_OPTIONS",
                errors={"command": scrub_text(str(args[0] if args else "")), "option": "shell"},
            )
        try:
            return subprocess.Popen(
                args, **kwargs
            )  # 安全:args 已通过 _validate_args 白名单校验,且明确拒绝 shell=True
        except FileNotFoundError as e:
            raise ExternalServiceError(
                message="外部命令不存在",
                code="SUBPROCESS_NOT_FOUND",
                errors={
                    "command": scrub_text(str(args[0] if args else "")),
                    "detail": self._truncate(scrub_text(str(e))),
                },
            ) from e
        except Exception as e:
            raise ExternalServiceError(
                message="外部命令执行失败",
                code="SUBPROCESS_FAILED",
                errors={
                    "command": scrub_text(str(args[0] if args else "")),
                    "detail": self._truncate(scrub_text(str(e))),
                },
            ) from e