"""SSH client helpers for remote filesystem and media inspection. All command execution goes through ``/bin/sh -c`` and all paths inserted into commands are shell-quoted by callers. This is important for two reasons: 1. The remote login shell may be fish/csh/etc.; internal commands are POSIX sh. 2. Media paths frequently contain spaces and punctuation. """ from __future__ import annotations import json import posixpath import shlex from dataclasses import dataclass from typing import Any import paramiko @dataclass class CommandResult: """Plain result object returned by remote command execution.""" command: str exit_status: int stdout: str stderr: str class RemoteSSHClient: """SSH helper for read-only inspection plus explicit job execution.""" def __init__( self, host: str, username: str, port: int = 22, key_filename: str | None = None, password: str | None = None, timeout: int = 20, ): if not host or not username: raise ValueError("SSH host and username are required") self.host = host self.username = username self.port = port self.key_filename = key_filename or None self.password = password or None self.timeout = timeout self._client: paramiko.SSHClient | None = None def connect(self) -> paramiko.SSHClient: """Create or reuse the Paramiko connection. Unknown host keys are rejected. Users should connect once manually with ssh so the server is present in known_hosts. """ if self._client: return self._client client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.RejectPolicy()) client.connect( self.host, port=self.port, username=self.username, key_filename=self.key_filename, password=self.password, timeout=self.timeout, ) self._client = client return client def close(self) -> None: if self._client: self._client.close() self._client = None def run(self, command: str, timeout: int | None = None) -> CommandResult: """Run a command through POSIX sh, independent of the user's login shell. Paramiko asks the SSH server to execute a command using the account's default shell. If that shell is fish/csh/etc., POSIX snippets containing `if ...; then`, pipes, redirects, or heredocs can fail. All internal app commands and job templates are written for POSIX shell, so explicitly dispatch through `/bin/sh -c`. """ client = self.connect() shell_command = f"/bin/sh -c {shlex.quote(command)}" stdin, stdout, stderr = client.exec_command(shell_command, timeout=timeout or self.timeout) exit_status = stdout.channel.recv_exit_status() return CommandResult( command=command, exit_status=exit_status, stdout=stdout.read().decode(errors="replace"), stderr=stderr.read().decode(errors="replace"), ) def list_dir(self, path: str) -> CommandResult: """List one remote directory as JSON. The command first verifies that ``path`` is a directory. Without that guard, running ``find`` on a file can look like an empty directory, which was a source of file-browser confusion. Output is NUL-delimited before Python serializes it, making spaces in filenames safe. """ # JSON-ish output: type, size, mtime epoch, filename. Handles spaces/newlines reasonably via NUL boundaries. quoted = shlex.quote(path) not_dir_message = shlex.quote(f"Not a directory: {path}") command = ( f"test -d {quoted} || " f"{{ echo {not_dir_message} >&2; exit 20; }}; " f"find {quoted} -maxdepth 1 -mindepth 1 -printf " "'%y\\t%s\\t%T@\\t%f\\0' | python3 -c " + shlex.quote( "import sys,json; data=sys.stdin.buffer.read().split(b'\\0'); " "rows=[]\n" "for row in data:\n" " if not row: continue\n" " t,s,m,n=row.decode('utf-8','replace').split('\\t',3)\n" " rows.append({'type':t,'size':int(s),'mtime':float(m),'name':n})\n" "print(json.dumps(rows))" ) ) return self.run(command) def stat_path(self, path: str) -> CommandResult: """Run stat for a remote file or directory path.""" quoted = shlex.quote(path) return self.run(f"stat --printf='%F\\n%s bytes\\n%y\\n%n\\n' {quoted}") def ffprobe_json(self, path: str) -> dict[str, Any]: """Run ffprobe and parse JSON output for a remote media file.""" quoted = shlex.quote(path) result = self.run( "ffprobe -v error -show_format -show_streams -print_format json " + quoted, timeout=60, ) if result.exit_status != 0: raise RuntimeError(result.stderr or result.stdout or "ffprobe failed") return json.loads(result.stdout) @staticmethod def join(parent: str, child: str) -> str: return posixpath.normpath(posixpath.join(parent, child))