from __future__ import annotations import io import json import os import queue import shutil import subprocess import threading from multiprocessing.context import AuthenticationError from multiprocessing.connection import Client from ._errors import ( DlpConfigurationError, DlpHelperStartError, DlpProtocolError, DlpSessionBusyError, DlpTransportError, ) from ._helper import ( CONTROL_LIMIT, HANDSHAKE_PREFIX, PROTOCOL_VERSION, render_helper_source, ) from ._raw import _PipeRawIO _SHUTDOWN_TIMEOUT = 5 def _locate_python() -> list[str] | None: for variable in ("DLP_IO_PYTHON", "DATAPACKER_DLP_PYTHON"): value = os.environ.get(variable) if value: return [value] executable = shutil.which("python") if executable: return [executable] if shutil.which("py"): return ["py", "-3"] return None def _helper_process_options(os_name=None) -> dict: if (os_name or os.name) == "nt": return {"creationflags": subprocess.CREATE_NO_WINDOW} return {} def _stop_process(process) -> None: if process.poll() is not None: return process.terminate() try: process.wait(timeout=5) except subprocess.TimeoutExpired: process.kill() try: process.wait(timeout=5) except subprocess.TimeoutExpired: pass def _close_process_pipes(process) -> None: for name in ("stdin", "stdout", "stderr"): stream = getattr(process, name, None) if stream is None: continue try: stream.close() except OSError: pass class DlpSession: """One authenticated, short-lived approved-Python helper session.""" def __init__(self, process, pipe_name: str, authkey: bytes) -> None: self._proc = process self.pipe_name = pipe_name self._authkey = authkey self._request_lock = threading.Lock() self._stream_lock = threading.Lock() self._state_lock = threading.RLock() self._active_raw = None self._closed = False @classmethod def start(cls, *, python_cmd=None, timeout=60): if os.name != "nt": raise DlpConfigurationError("dlp-io bridge reads require Windows AF_PIPE") if timeout <= 0: raise DlpConfigurationError("startup timeout must be positive") command = list(python_cmd) if python_cmd else _locate_python() if not command: raise DlpHelperStartError( "no approved Python found; configure DLP_IO_PYTHON" ) authkey = os.urandom(32) source = render_helper_source(authkey) try: process = subprocess.Popen( command + ["-u", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **_helper_process_options(), ) except OSError as exc: raise DlpHelperStartError( "failed to start approved Python %r: %s" % (command, exc) ) from exc try: process.stdin.write(source.encode("utf-8")) process.stdin.close() except (BrokenPipeError, OSError) as exc: _stop_process(process) raise DlpHelperStartError("failed to inject helper source: %s" % exc) from exc result = queue.Queue() def read_handshake() -> None: try: result.put(process.stdout.readline()) except Exception as exc: result.put(exc) thread = threading.Thread(target=read_handshake, daemon=True) thread.start() try: line = result.get(timeout=timeout) except queue.Empty as exc: _stop_process(process) raise DlpHelperStartError( "timed out waiting for helper handshake after %s seconds" % timeout ) from exc if isinstance(line, Exception): _stop_process(process) raise DlpHelperStartError("failed to read helper handshake: %s" % line) decoded = line.decode("utf-8", "replace").strip() if not decoded.startswith(HANDSHAKE_PREFIX): _stop_process(process) stderr = process.stderr.read().decode("utf-8", "replace").strip() raise DlpHelperStartError( "invalid helper handshake %r; stderr: %s" % (decoded, stderr) ) return cls(process, decoded[len(HANDSHAKE_PREFIX):], authkey) def _ensure_open(self) -> None: if self._closed: raise RuntimeError("dlp-io session is closed") def _validate_header(self, header) -> dict: if not isinstance(header, dict): raise DlpProtocolError("helper response must be a JSON object") if header.get("version") != PROTOCOL_VERSION: raise DlpProtocolError("helper response has an unsupported version") if not isinstance(header.get("ok"), bool): raise DlpProtocolError("helper response is missing boolean ok") return header def _request(self, request): with self._request_lock: try: encoded = json.dumps(request).encode("utf-8") except (TypeError, ValueError) as exc: raise DlpProtocolError("helper request is not JSON serializable") from exc try: conn = Client(self.pipe_name, family="AF_PIPE", authkey=self._authkey) except AuthenticationError as exc: raise DlpTransportError("failed to authenticate helper pipe") from exc except (EOFError, OSError) as exc: raise DlpTransportError("failed to connect to helper pipe") from exc try: try: conn.send_bytes(encoded) except (EOFError, OSError) as exc: raise DlpTransportError("failed to send helper request") from exc try: payload = conn.recv_bytes(maxlength=CONTROL_LIMIT) except (EOFError, OSError) as exc: raise DlpTransportError("failed to receive helper response") from exc try: header = json.loads(payload.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise DlpProtocolError("helper response is not valid UTF-8 JSON") from exc return conn, self._validate_header(header) except Exception: conn.close() raise def ping(self) -> dict: with self._state_lock: self._ensure_open() conn, header = self._request({"version": PROTOCOL_VERSION, "op": "ping"}) conn.close() if not header["ok"]: raise DlpProtocolError(header.get("error", "ping failed")) return header def _open_conn(self, path: str, offset: int = 0): conn, header = self._request( { "version": PROTOCOL_VERSION, "op": "read", "path": path, "offset": offset, } ) if not header["ok"]: conn.close() raise OSError(header.get("error", "helper read failed")) size = header.get("size") if isinstance(size, bool) or not isinstance(size, int) or size < 0: conn.close() raise DlpProtocolError("helper returned an invalid file size") return conn, size def _release_stream(self, raw) -> None: with self._state_lock: if self._active_raw is raw: self._active_raw = None self._stream_lock.release() def _reopen_stream(self, path: str, offset: int): with self._state_lock: self._ensure_open() return self._open_conn(path, offset) def open_raw(self, path): with self._state_lock: self._ensure_open() if not self._stream_lock.acquire(blocking=False): raise DlpSessionBusyError("dlp-io session already has an active stream") normalized = os.fsdecode(os.fspath(path)) try: conn, size = self._open_conn(normalized) raw = _PipeRawIO( conn=conn, size=size, path=normalized, offset=0, reopen=self._reopen_stream, release=self._release_stream, ) with self._state_lock: if self._closed: raw.close() raise RuntimeError("dlp-io session is closed") self._active_raw = raw return raw except Exception: self._stream_lock.release() raise def open( self, file, mode="r", buffering=-1, encoding=None, errors=None, newline=None, ): from ._api import _validate_path_mode, _wrap_reader buffering = _validate_path_mode(mode, buffering, encoding, errors, newline) if "r" not in mode or "+" in mode: raise io.UnsupportedOperation("DlpSession.open supports read-only modes") return _wrap_reader( self.open_raw(file), mode, buffering, encoding, errors, newline ) def open_read(self, path): """Compatibility alias returning a buffered binary reader.""" return self.open(path, "rb") def close(self) -> None: with self._state_lock: if self._closed: return self._closed = True active = self._active_raw if active is not None: active.close() def request_quit() -> None: try: conn, header = self._request( {"version": PROTOCOL_VERSION, "op": "quit"} ) conn.close() if not header["ok"]: raise DlpProtocolError(header.get("error", "helper quit failed")) except Exception: pass quit_thread = threading.Thread(target=request_quit, daemon=True) quit_thread.start() quit_thread.join(timeout=_SHUTDOWN_TIMEOUT) try: if quit_thread.is_alive(): _stop_process(self._proc) quit_thread.join(timeout=_SHUTDOWN_TIMEOUT) else: try: self._proc.wait(timeout=_SHUTDOWN_TIMEOUT) except subprocess.TimeoutExpired: _stop_process(self._proc) finally: _close_process_pipes(self._proc) def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): self.close() return False