Skip to main content

agent_first_psql/
container_transport.rs

1use crate::conn::resolve_pg_config;
2use crate::db::ConnectError;
3use crate::types::SessionConfig;
4use std::pin::Pin;
5use std::process::Stdio;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex};
8use std::task::{Context, Poll};
9use std::time::{Duration, SystemTime, UNIX_EPOCH};
10use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf};
11use tokio::process::{ChildStderr, ChildStdin, ChildStdout};
12use tokio::sync::oneshot;
13use tokio_postgres::config::Host;
14use tokio_postgres::{Client, NoTls};
15
16const DEFAULT_DRIVER: &str = "docker";
17const DEFAULT_REMOTE_HOST: &str = "127.0.0.1";
18const DEFAULT_REMOTE_PORT: u16 = 5432;
19const STDERR_CAPTURE_LIMIT: usize = 8 * 1024;
20const STDERR_HINT_BYTES: usize = 512;
21const BRIDGE_READY_PREFIX: &str = "AFPSQL_BRIDGE_OK";
22const BRIDGE_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2);
23
24static BRIDGE_NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
25
26fn generate_bridge_nonce() -> String {
27    let nanos = SystemTime::now()
28        .duration_since(UNIX_EPOCH)
29        .map(|d| d.as_nanos() as u64)
30        .unwrap_or(0);
31    let counter = BRIDGE_NONCE_COUNTER.fetch_add(1, Ordering::Relaxed);
32    let mixed = nanos
33        .wrapping_mul(0x9E37_79B9_7F4A_7C15)
34        .wrapping_add(counter);
35    format!("{mixed:016x}")
36}
37
38const PYTHON_BRIDGE: &str = r#"import os,select,socket,sys
39mode=sys.argv[1]
40if mode=="tcp":
41    s=socket.create_connection((sys.argv[2], int(sys.argv[3])))
42elif mode=="unix":
43    s=socket.socket(socket.AF_UNIX)
44    s.connect(sys.argv[2])
45else:
46    sys.stderr.write("unsupported bridge mode: "+mode+"\n")
47    sys.exit(2)
48stdin_obj=getattr(sys.stdin,"buffer",sys.stdin)
49stdout_obj=getattr(sys.stdout,"buffer",sys.stdout)
50stdin_fd=stdin_obj.fileno()
51stdout_fd=stdout_obj.fileno()
52stdin_open=True
53while True:
54    readers=[s]
55    if stdin_open:
56        readers.append(stdin_fd)
57    ready,_,_=select.select(readers,[],[])
58    if stdin_fd in ready:
59        data=os.read(stdin_fd,65536)
60        if data:
61            s.sendall(data)
62        else:
63            stdin_open=False
64            try:
65                s.shutdown(socket.SHUT_WR)
66            except OSError:
67                pass
68    if s in ready:
69        data=s.recv(65536)
70        if data:
71            os.write(stdout_fd,data)
72        else:
73            break
74"#;
75
76const PERL_BRIDGE: &str = r#"use strict; use warnings; use IO::Socket::INET; use IO::Socket::UNIX; use IO::Select; use Socket qw(SOCK_STREAM);
77my $mode = shift @ARGV;
78my $sock;
79if ($mode eq "tcp") {
80    my ($host, $port) = @ARGV;
81    $sock = IO::Socket::INET->new(PeerHost => $host, PeerPort => $port, Proto => "tcp") or die "connect tcp failed: $!";
82} elsif ($mode eq "unix") {
83    my ($path) = @ARGV;
84    $sock = IO::Socket::UNIX->new(Type => SOCK_STREAM, Peer => $path) or die "connect unix failed: $!";
85} else {
86    die "unsupported bridge mode: $mode";
87}
88binmode STDIN; binmode STDOUT; binmode $sock;
89my $select = IO::Select->new($sock, \*STDIN);
90while (1) {
91    for my $fh ($select->can_read) {
92        if ($fh == \*STDIN) {
93            my $buf = "";
94            my $n = sysread(STDIN, $buf, 65536);
95            die "read stdin failed: $!" unless defined $n;
96            if ($n == 0) {
97                $select->remove(\*STDIN);
98                shutdown($sock, 1);
99            } else {
100                write_all($sock, $buf);
101            }
102        } else {
103            my $buf = "";
104            my $n = sysread($sock, $buf, 65536);
105            die "read socket failed: $!" unless defined $n;
106            exit 0 if $n == 0;
107            write_all(\*STDOUT, $buf);
108        }
109    }
110}
111sub write_all {
112    my ($fh, $buf) = @_;
113    my $off = 0;
114    my $len = length($buf);
115    while ($off < $len) {
116        my $n = syswrite($fh, $buf, $len - $off, $off);
117        die "write failed: $!" unless defined $n;
118        $off += $n;
119    }
120}
121"#;
122
123const SHELL_BRIDGE_BODY: &str = r#"if command -v python3 >/dev/null 2>&1; then
124  echo "AFPSQL_BRIDGE_OK $AFPSQL_BRIDGE_NONCE" >&2
125  exec python3 -c "$AFPSQL_CONTAINER_PY_BRIDGE" "$@"
126fi
127if command -v python >/dev/null 2>&1; then
128  echo "AFPSQL_BRIDGE_OK $AFPSQL_BRIDGE_NONCE" >&2
129  exec python -c "$AFPSQL_CONTAINER_PY_BRIDGE" "$@"
130fi
131if command -v perl >/dev/null 2>&1; then
132  echo "AFPSQL_BRIDGE_OK $AFPSQL_BRIDGE_NONCE" >&2
133  exec perl -e "$AFPSQL_CONTAINER_PERL_BRIDGE" "$@"
134fi
135echo "afpsql container bridge requires python3, python, or perl in the container" >&2
136exit 127
137"#;
138
139pub struct ContainerBridgeGuard {
140    child: Option<tokio::process::Child>,
141    connection_task: Option<tokio::task::JoinHandle<()>>,
142    stderr_task: Option<tokio::task::JoinHandle<()>>,
143}
144
145impl ContainerBridgeGuard {
146    pub fn is_finished(&self) -> bool {
147        self.connection_task
148            .as_ref()
149            .map(|task| task.is_finished())
150            .unwrap_or(true)
151    }
152
153    pub async fn shutdown(mut self, timeout: Duration) {
154        if let Some(task) = self.connection_task.take() {
155            let mut task = task;
156            if tokio::time::timeout(timeout, &mut task).await.is_err() {
157                task.abort();
158            }
159        }
160        if let Some(task) = self.stderr_task.take() {
161            let mut task = task;
162            if tokio::time::timeout(timeout, &mut task).await.is_err() {
163                task.abort();
164            }
165        }
166        if let Some(mut child) = self.child.take() {
167            let _ = child.start_kill();
168            let _ = tokio::time::timeout(timeout, child.wait()).await;
169        }
170    }
171}
172
173impl Drop for ContainerBridgeGuard {
174    fn drop(&mut self) {
175        if let Some(task) = self.connection_task.as_ref() {
176            task.abort();
177        }
178        if let Some(task) = self.stderr_task.as_ref() {
179            task.abort();
180        }
181        if let Some(child) = self.child.as_mut() {
182            let _ = child.start_kill();
183        }
184    }
185}
186
187struct ContainerStdioStream {
188    stdout: ChildStdout,
189    stdin: ChildStdin,
190}
191
192impl AsyncRead for ContainerStdioStream {
193    fn poll_read(
194        mut self: Pin<&mut Self>,
195        cx: &mut Context<'_>,
196        buf: &mut ReadBuf<'_>,
197    ) -> Poll<std::io::Result<()>> {
198        Pin::new(&mut self.stdout).poll_read(cx, buf)
199    }
200}
201
202impl AsyncWrite for ContainerStdioStream {
203    fn poll_write(
204        mut self: Pin<&mut Self>,
205        cx: &mut Context<'_>,
206        buf: &[u8],
207    ) -> Poll<std::io::Result<usize>> {
208        Pin::new(&mut self.stdin).poll_write(cx, buf)
209    }
210
211    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
212        Pin::new(&mut self.stdin).poll_flush(cx)
213    }
214
215    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
216        Pin::new(&mut self.stdin).poll_shutdown(cx)
217    }
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221enum ContainerDriver {
222    Docker,
223    Podman,
224    Nerdctl,
225    Compose,
226    Kubectl,
227}
228
229impl ContainerDriver {
230    fn parse(value: &str) -> Result<Self, String> {
231        match value {
232            "docker" => Ok(Self::Docker),
233            "podman" => Ok(Self::Podman),
234            "nerdctl" => Ok(Self::Nerdctl),
235            "compose" | "docker-compose" => Ok(Self::Compose),
236            "kubectl" | "kubernetes" | "k8s" => Ok(Self::Kubectl),
237            _ => Err(format!(
238                "unsupported container driver `{value}`; expected docker, podman, nerdctl, compose, or kubectl"
239            )),
240        }
241    }
242
243    fn default_runtime(self) -> &'static str {
244        match self {
245            Self::Docker | Self::Compose => "docker",
246            Self::Podman => "podman",
247            Self::Nerdctl => "nerdctl",
248            Self::Kubectl => "kubectl",
249        }
250    }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
254struct ContainerSettings {
255    driver: ContainerDriver,
256    runtime: String,
257    target: String,
258    user: Option<String>,
259    namespace: Option<String>,
260    context: Option<String>,
261    compose_files: Vec<String>,
262    compose_project: Option<String>,
263    pod_container: Option<String>,
264    ssh_destination: Option<String>,
265    ssh_options: Vec<String>,
266}
267
268#[derive(Debug, Clone, PartialEq, Eq)]
269enum ContainerTarget {
270    Tcp { host: String, port: u16 },
271    UnixSocket { path: String },
272}
273
274pub async fn connect_stdio_bridge(
275    cfg: &SessionConfig,
276) -> Result<(Client, ContainerBridgeGuard), ConnectError> {
277    let settings = resolve_container_settings(cfg)?;
278    let target = resolve_container_target(cfg)?;
279    let nonce = generate_bridge_nonce();
280    let (program, args) =
281        build_bridge_process(&settings, &target, &nonce).map_err(ConnectError::new)?;
282    let mut child = tokio::process::Command::new(&program)
283        .args(&args)
284        .stdin(Stdio::piped())
285        .stdout(Stdio::piped())
286        .stderr(Stdio::piped())
287        .kill_on_drop(true)
288        .spawn()
289        .map_err(|e| ConnectError::new(format!("start container bridge failed: {e}")))?;
290    let stderr = child.stderr.take();
291    let stderr_capture = Arc::new(Mutex::new(Vec::new()));
292    let (stderr_task, handshake_rx) = stderr
293        .map(|stderr| spawn_stderr_capture(stderr, Arc::clone(&stderr_capture), nonce.clone()))
294        .map(|(task, rx)| (Some(task), Some(rx)))
295        .unwrap_or((None, None));
296
297    let stdin = child.stdin.take().ok_or_else(|| {
298        ConnectError::new("start container bridge failed: stdin pipe unavailable")
299    })?;
300    let stdout = child.stdout.take().ok_or_else(|| {
301        ConnectError::new("start container bridge failed: stdout pipe unavailable")
302    })?;
303    if let Err(mut err) = wait_bridge_handshake(&mut child, &stderr_capture, handshake_rx).await {
304        // If the runtime could not find/exec the target, list the real container
305        // names instead of telling the agent to "check the target name".
306        let stderr_text = captured_stderr(&stderr_capture);
307        if stderr_indicates_missing_target(&stderr_text)
308            && let Some(list) = list_container_targets(&settings).await
309        {
310            err.hint = Some(match err.hint.take() {
311                Some(base) => format!("{base}; available containers: {list}"),
312                None => format!("available containers: {list}"),
313            });
314        }
315        return Err(err);
316    }
317    let stream = ContainerStdioStream { stdout, stdin };
318    let pg_cfg = resolve_pg_config(cfg)
319        .map_err(|e| ConnectError::new(format!("invalid container connection config: {e}")))?;
320    let (client, connection) = match pg_cfg.connect_raw(stream, NoTls).await {
321        Ok(connection) => connection,
322        Err(e) => {
323            let status = match tokio::time::timeout(Duration::from_millis(100), child.wait()).await
324            {
325                Ok(Ok(status)) => Some(status),
326                _ => child.try_wait().ok().flatten(),
327            };
328            tokio::time::sleep(Duration::from_millis(20)).await;
329            let stderr_text = captured_stderr(&stderr_capture);
330            return Err(enrich_container_connect_error(
331                ConnectError::from_pg_error("connect through container bridge failed", e),
332                status,
333                &stderr_text,
334            ));
335        }
336    };
337    let connection_task = tokio::spawn(async move {
338        let _ = connection.await;
339    });
340
341    Ok((
342        client,
343        ContainerBridgeGuard {
344            child: Some(child),
345            connection_task: Some(connection_task),
346            stderr_task,
347        },
348    ))
349}
350
351fn spawn_stderr_capture(
352    mut stderr: ChildStderr,
353    capture: Arc<Mutex<Vec<u8>>>,
354    nonce: String,
355) -> (tokio::task::JoinHandle<()>, oneshot::Receiver<bool>) {
356    let (handshake_tx, handshake_rx) = oneshot::channel();
357    let task = tokio::spawn(async move {
358        let mut buf = [0u8; 1024];
359        let mut pending = Vec::new();
360        let mut handshake_tx = Some(handshake_tx);
361        while let Ok(n) = stderr.read(&mut buf).await {
362            if n == 0 {
363                break;
364            }
365            pending.extend_from_slice(&buf[..n]);
366            while let Some(pos) = pending.iter().position(|b| *b == b'\n') {
367                let line = pending.drain(..=pos).collect::<Vec<_>>();
368                handle_stderr_line(line, &capture, &mut handshake_tx, &nonce);
369            }
370        }
371        if !pending.is_empty() {
372            handle_stderr_line(pending, &capture, &mut handshake_tx, &nonce);
373        }
374        if let Some(tx) = handshake_tx.take() {
375            let _ = tx.send(false);
376        }
377    });
378    (task, handshake_rx)
379}
380
381fn handle_stderr_line(
382    line: Vec<u8>,
383    capture: &Arc<Mutex<Vec<u8>>>,
384    handshake_tx: &mut Option<oneshot::Sender<bool>>,
385    nonce: &str,
386) {
387    if stderr_line_is_banner(&line, nonce) {
388        if let Some(tx) = handshake_tx.take() {
389            let _ = tx.send(true);
390        }
391        return;
392    }
393    append_stderr_capture(capture, &line);
394}
395
396fn stderr_line_is_banner(line: &[u8], nonce: &str) -> bool {
397    let line = line.strip_suffix(b"\n").unwrap_or(line);
398    let line = line.strip_suffix(b"\r").unwrap_or(line);
399    let expected = format!("{BRIDGE_READY_PREFIX} {nonce}");
400    line == expected.as_bytes()
401}
402
403fn append_stderr_capture(capture: &Arc<Mutex<Vec<u8>>>, bytes: &[u8]) {
404    if let Ok(mut captured) = capture.lock() {
405        let remaining = STDERR_CAPTURE_LIMIT.saturating_sub(captured.len());
406        if remaining > 0 {
407            captured.extend_from_slice(&bytes[..bytes.len().min(remaining)]);
408        }
409    }
410}
411
412async fn wait_bridge_handshake(
413    child: &mut tokio::process::Child,
414    stderr_capture: &Arc<Mutex<Vec<u8>>>,
415    handshake_rx: Option<oneshot::Receiver<bool>>,
416) -> Result<(), ConnectError> {
417    let Some(handshake_rx) = handshake_rx else {
418        return Err(handshake_connect_error(
419            child,
420            stderr_capture,
421            "container bridge stderr pipe unavailable for startup handshake",
422        )
423        .await);
424    };
425
426    match tokio::time::timeout(BRIDGE_HANDSHAKE_TIMEOUT, handshake_rx).await {
427        Ok(Ok(true)) => Ok(()),
428        Ok(Ok(false)) => Err(handshake_connect_error(
429            child,
430            stderr_capture,
431            "container bridge exited before startup handshake",
432        )
433        .await),
434        Ok(Err(_)) => Err(handshake_connect_error(
435            child,
436            stderr_capture,
437            "container bridge startup handshake channel closed",
438        )
439        .await),
440        Err(_) => Err(handshake_connect_error(
441            child,
442            stderr_capture,
443            "container bridge did not emit startup handshake before timeout",
444        )
445        .await),
446    }
447}
448
449async fn handshake_connect_error(
450    child: &mut tokio::process::Child,
451    stderr_capture: &Arc<Mutex<Vec<u8>>>,
452    message: &str,
453) -> ConnectError {
454    let status = child.try_wait().ok().flatten();
455    let stderr_text = captured_stderr(stderr_capture);
456    let mut err = ConnectError::new(message);
457    err.hint = Some(if stderr_text.is_empty() {
458        format!(
459            "the container bridge never reported {BRIDGE_READY_PREFIX}; check target name, runtime access, /bin/sh, and bridge interpreter prerequisites"
460        )
461    } else {
462        format!(
463            "the container bridge wrote diagnostics before {BRIDGE_READY_PREFIX}; container bridge stderr: {stderr_text}"
464        )
465    });
466    enrich_container_connect_error(err, status, &stderr_text)
467}
468
469fn captured_stderr(capture: &Arc<Mutex<Vec<u8>>>) -> String {
470    capture
471        .lock()
472        .ok()
473        .map(|captured| sanitize_diagnostic(&captured))
474        .filter(|text| !text.is_empty())
475        .unwrap_or_default()
476}
477
478fn sanitize_diagnostic(bytes: &[u8]) -> String {
479    let text = String::from_utf8_lossy(bytes);
480    let mut out = String::with_capacity(STDERR_HINT_BYTES);
481    for ch in text.chars() {
482        let mapped = if matches!(ch, '\n' | '\t') {
483            ch
484        } else if ch.is_control() {
485            ' '
486        } else {
487            ch
488        };
489        if out.len() + mapped.len_utf8() > STDERR_HINT_BYTES {
490            break;
491        }
492        out.push(mapped);
493    }
494    out.trim().to_string()
495}
496
497fn enrich_container_connect_error(
498    mut err: ConnectError,
499    status: Option<std::process::ExitStatus>,
500    stderr: &str,
501) -> ConnectError {
502    if let Some(hint) = container_bridge_hint(status, stderr) {
503        err.hint = Some(match err.hint.take() {
504            Some(base) => format!("{base}; {hint}"),
505            None => hint,
506        });
507    }
508    err
509}
510
511fn container_bridge_hint(status: Option<std::process::ExitStatus>, stderr: &str) -> Option<String> {
512    let trimmed = stderr.trim();
513    let lower = trimmed.to_ascii_lowercase();
514    let base = if lower.contains("requires python3, python, or perl") {
515        "the container bridge started but the container has no supported interpreter; install python3/python/perl, use a sidecar, or connect through the host instead"
516    } else if lower.contains("sh:") && lower.contains("not found") {
517        "the container bridge requires /bin/sh or compatible shell in the target container"
518    } else if lower.contains("no such container")
519        || lower.contains("not found")
520        || lower.contains("is not running")
521    {
522        "the container runtime could not exec into the target; check the container target name and running state"
523    } else if lower.contains("error from server") || lower.contains("pods") {
524        "kubectl could not exec into the target; check context, namespace, pod name, and cluster access"
525    } else if matches!(status.and_then(|s| s.code()), Some(125..=127)) {
526        "the container runtime or bridge command exited before PostgreSQL handshake; check runtime access, target name, shell, and bridge prerequisites"
527    } else if !trimmed.is_empty() {
528        "the container bridge wrote diagnostics before PostgreSQL handshake failed"
529    } else {
530        return None;
531    };
532
533    if trimmed.is_empty() {
534        Some(base.to_string())
535    } else {
536        Some(format!("{base}; container bridge stderr: {trimmed}"))
537    }
538}
539
540fn stderr_indicates_missing_target(stderr: &str) -> bool {
541    let lower = stderr.to_ascii_lowercase();
542    lower.contains("no such container")
543        || lower.contains("is not running")
544        || (lower.contains("not found") && lower.contains("container"))
545}
546
547/// A best-effort diagnostic listing is not worth blocking the error path on.
548const CONTAINER_LIST_TIMEOUT: Duration = Duration::from_secs(3);
549
550/// List the runtime's actual container names so a wrong-target error can name the
551/// real options. Best-effort; `None` on any failure or unsupported driver.
552async fn list_container_targets(settings: &ContainerSettings) -> Option<String> {
553    if settings.ssh_destination.is_some() {
554        return None;
555    }
556    let mut args = Vec::new();
557    match settings.driver {
558        ContainerDriver::Docker | ContainerDriver::Podman | ContainerDriver::Nerdctl => {
559            if settings.driver == ContainerDriver::Docker
560                && let Some(context) = settings.context.as_ref()
561            {
562                args.push(format!("--context={context}"));
563            }
564            // Running only — you can only exec into a running container, and the
565            // generic hint already covers the stopped-container case.
566            args.push("ps".to_string());
567            args.push("--format".to_string());
568            args.push("{{.Names}}".to_string());
569        }
570        // Compose service names and kubectl pods need different listings; skip
571        // for now and fall back to the generic hint.
572        ContainerDriver::Compose | ContainerDriver::Kubectl => return None,
573    }
574    let output = tokio::time::timeout(
575        CONTAINER_LIST_TIMEOUT,
576        tokio::process::Command::new(&settings.runtime)
577            .args(&args)
578            .stdin(Stdio::null())
579            .stdout(Stdio::piped())
580            .stderr(Stdio::null())
581            .kill_on_drop(true)
582            .output(),
583    )
584    .await
585    .ok()?
586    .ok()?;
587    let names: Vec<String> = String::from_utf8_lossy(&output.stdout)
588        .lines()
589        .map(|line| line.trim())
590        .filter(|line| !line.is_empty())
591        .map(|line| line.to_string())
592        .collect();
593    if names.is_empty() {
594        return None;
595    }
596    // Cap the list but never hide truncation — a silently cut list reads as
597    // "these are all of them" when the real target was dropped.
598    const MAX_LISTED: usize = 30;
599    if names.len() > MAX_LISTED {
600        let shown = names[..MAX_LISTED].join(", ");
601        let extra = names.len() - MAX_LISTED;
602        Some(format!("{shown} (+{extra} more)"))
603    } else {
604        Some(names.join(", "))
605    }
606}
607
608fn resolve_container_settings(cfg: &SessionConfig) -> Result<ContainerSettings, String> {
609    let target = cfg
610        .container
611        .target
612        .clone()
613        .or_else(|| env_nonempty("AFPSQL_CONTAINER"));
614    let ssh_destination = cfg
615        .ssh
616        .destination
617        .clone()
618        .or_else(|| env_nonempty("AFPSQL_SSH"));
619    let namespace = cfg
620        .container
621        .namespace
622        .clone()
623        .or_else(|| env_nonempty("AFPSQL_CONTAINER_NAMESPACE"));
624    let context = cfg
625        .container
626        .context
627        .clone()
628        .or_else(|| env_nonempty("AFPSQL_CONTAINER_CONTEXT"));
629    let compose_files = if cfg.container.compose_files.is_empty() {
630        env_colon_list("AFPSQL_CONTAINER_COMPOSE_FILE")
631    } else {
632        cfg.container.compose_files.clone()
633    };
634    let compose_project = cfg
635        .container
636        .compose_project
637        .clone()
638        .or_else(|| env_nonempty("AFPSQL_CONTAINER_COMPOSE_PROJECT"));
639    let pod_container = cfg
640        .container
641        .pod_container
642        .clone()
643        .or_else(|| env_nonempty("AFPSQL_CONTAINER_POD_CONTAINER"));
644    let has_container_fields = target.is_some()
645        || cfg.container.driver.is_some()
646        || cfg.container.runtime.is_some()
647        || cfg.container.user.is_some()
648        || namespace.is_some()
649        || context.is_some()
650        || !compose_files.is_empty()
651        || compose_project.is_some()
652        || pod_container.is_some();
653
654    let Some(target) = target else {
655        if has_container_fields {
656            return Err(
657                "--container is required when container transport options are set".to_string(),
658            );
659        }
660        return Err("--container is required for container transport".to_string());
661    };
662    if target.trim().is_empty() {
663        return Err("--container requires a non-empty target name".to_string());
664    }
665    if let Some(destination) = ssh_destination.as_ref() {
666        if destination.trim().is_empty() {
667            return Err("--ssh requires a non-empty USER@HOST destination".to_string());
668        }
669    } else if !cfg.ssh.options.is_empty() {
670        return Err(
671            "--ssh is required when --ssh-option is combined with container transport".to_string(),
672        );
673    }
674    if cfg.ssh.has_tunnel_or_bridge_options() {
675        return Err("container transport with --ssh supports only --ssh and --ssh-option; SSH tunnel and sudo bridge options are for non-container SSH transport".to_string());
676    }
677
678    let driver_name = cfg
679        .container
680        .driver
681        .clone()
682        .or_else(|| env_nonempty("AFPSQL_CONTAINER_DRIVER"))
683        .unwrap_or_else(|| DEFAULT_DRIVER.to_string());
684    let driver = ContainerDriver::parse(&driver_name)?;
685    let user = cfg
686        .container
687        .user
688        .clone()
689        .or_else(|| env_nonempty("AFPSQL_CONTAINER_USER"));
690    if driver == ContainerDriver::Kubectl && user.is_some() {
691        return Err(
692            "--container-user is not supported with --container-driver kubectl".to_string(),
693        );
694    }
695    validate_driver_scoped_options(
696        driver,
697        namespace.as_ref(),
698        context.as_ref(),
699        &compose_files,
700        compose_project.as_ref(),
701        pod_container.as_ref(),
702    )?;
703
704    Ok(ContainerSettings {
705        runtime: cfg
706            .container
707            .runtime
708            .clone()
709            .or_else(|| env_nonempty("AFPSQL_CONTAINER_RUNTIME"))
710            .unwrap_or_else(|| driver.default_runtime().to_string()),
711        driver,
712        target,
713        user,
714        namespace,
715        context,
716        compose_files,
717        compose_project,
718        pod_container,
719        ssh_destination,
720        ssh_options: cfg.ssh.options.clone(),
721    })
722}
723
724fn validate_driver_scoped_options(
725    driver: ContainerDriver,
726    namespace: Option<&String>,
727    context: Option<&String>,
728    compose_files: &[String],
729    compose_project: Option<&String>,
730    pod_container: Option<&String>,
731) -> Result<(), String> {
732    match driver {
733        ContainerDriver::Docker => {
734            reject_present(
735                namespace,
736                "--container-namespace requires --container-driver kubectl",
737            )?;
738            reject_present(
739                pod_container,
740                "--container-pod-container requires --container-driver kubectl",
741            )?;
742            reject_nonempty(
743                compose_files,
744                "--container-compose-file requires --container-driver compose",
745            )?;
746            reject_present(
747                compose_project,
748                "--container-compose-project requires --container-driver compose",
749            )?;
750        }
751        ContainerDriver::Compose => {
752            reject_present(
753                namespace,
754                "--container-namespace requires --container-driver kubectl",
755            )?;
756            reject_present(
757                context,
758                "--container-context requires --container-driver docker or kubectl",
759            )?;
760            reject_present(
761                pod_container,
762                "--container-pod-container requires --container-driver kubectl",
763            )?;
764        }
765        ContainerDriver::Kubectl => {
766            reject_nonempty(
767                compose_files,
768                "--container-compose-file requires --container-driver compose",
769            )?;
770            reject_present(
771                compose_project,
772                "--container-compose-project requires --container-driver compose",
773            )?;
774        }
775        ContainerDriver::Podman | ContainerDriver::Nerdctl => {
776            reject_present(
777                namespace,
778                "--container-namespace requires --container-driver kubectl",
779            )?;
780            reject_present(
781                context,
782                "--container-context requires --container-driver docker or kubectl",
783            )?;
784            reject_nonempty(
785                compose_files,
786                "--container-compose-file requires --container-driver compose",
787            )?;
788            reject_present(
789                compose_project,
790                "--container-compose-project requires --container-driver compose",
791            )?;
792            reject_present(
793                pod_container,
794                "--container-pod-container requires --container-driver kubectl",
795            )?;
796        }
797    }
798    Ok(())
799}
800
801fn reject_present<T>(value: Option<T>, message: &str) -> Result<(), String> {
802    if value.is_some() {
803        Err(message.to_string())
804    } else {
805        Ok(())
806    }
807}
808
809fn reject_nonempty<T>(value: &[T], message: &str) -> Result<(), String> {
810    if value.is_empty() {
811        Ok(())
812    } else {
813        Err(message.to_string())
814    }
815}
816
817fn resolve_container_target(cfg: &SessionConfig) -> Result<ContainerTarget, String> {
818    let pg_cfg =
819        resolve_pg_config(cfg).map_err(|e| format!("invalid container connection config: {e}"))?;
820    let hosts = pg_cfg.get_hosts();
821    if hosts.len() > 1 {
822        return Err("container transport supports a single PostgreSQL host".to_string());
823    }
824    let ports = pg_cfg.get_ports();
825    if ports.len() > 1 {
826        return Err("container transport supports a single PostgreSQL port".to_string());
827    }
828    let port = ports.first().copied().unwrap_or(DEFAULT_REMOTE_PORT);
829    match hosts.first() {
830        Some(Host::Tcp(host)) => {
831            if host.starts_with('/') {
832                Ok(ContainerTarget::UnixSocket {
833                    path: socket_file_from_dir(host, port),
834                })
835            } else {
836                Ok(ContainerTarget::Tcp {
837                    host: host.clone(),
838                    port,
839                })
840            }
841        }
842        #[cfg(unix)]
843        Some(Host::Unix(path)) => Ok(ContainerTarget::UnixSocket {
844            path: socket_file_from_dir(&path.to_string_lossy(), port),
845        }),
846        None => Ok(ContainerTarget::Tcp {
847            host: DEFAULT_REMOTE_HOST.to_string(),
848            port,
849        }),
850    }
851}
852
853fn socket_file_from_dir(dir: &str, port: u16) -> String {
854    format!("{}/.s.PGSQL.{port}", dir.trim_end_matches('/'))
855}
856
857fn build_bridge_process(
858    settings: &ContainerSettings,
859    target: &ContainerTarget,
860    nonce: &str,
861) -> Result<(String, Vec<String>), String> {
862    if settings.ssh_destination.is_some() {
863        Ok((
864            "ssh".to_string(),
865            build_bridge_ssh_args(settings, target, nonce)?,
866        ))
867    } else {
868        Ok((
869            settings.runtime.clone(),
870            build_container_exec_args(settings, target, nonce)?,
871        ))
872    }
873}
874
875fn build_container_exec_args(
876    settings: &ContainerSettings,
877    target: &ContainerTarget,
878    nonce: &str,
879) -> Result<Vec<String>, String> {
880    let command = bridge_command_args(target, nonce);
881    let mut args = Vec::new();
882    match settings.driver {
883        ContainerDriver::Docker | ContainerDriver::Podman | ContainerDriver::Nerdctl => {
884            if settings.driver == ContainerDriver::Docker
885                && let Some(context) = settings.context.as_ref()
886            {
887                args.push(format!("--context={context}"));
888            }
889            args.push("exec".to_string());
890            args.push("-i".to_string());
891            if let Some(user) = settings.user.as_ref() {
892                args.push("--user".to_string());
893                args.push(user.clone());
894            }
895            args.push(settings.target.clone());
896            args.extend(command);
897        }
898        ContainerDriver::Compose => {
899            if !is_docker_compose_runtime(&settings.runtime) {
900                args.push("compose".to_string());
901            }
902            for file in &settings.compose_files {
903                args.push("-f".to_string());
904                args.push(file.clone());
905            }
906            if let Some(project) = settings.compose_project.as_ref() {
907                args.push("-p".to_string());
908                args.push(project.clone());
909            }
910            args.push("exec".to_string());
911            args.push("-T".to_string());
912            if let Some(user) = settings.user.as_ref() {
913                args.push("--user".to_string());
914                args.push(user.clone());
915            }
916            args.push(settings.target.clone());
917            args.extend(command);
918        }
919        ContainerDriver::Kubectl => {
920            if let Some(context) = settings.context.as_ref() {
921                args.push(format!("--context={context}"));
922            }
923            if let Some(namespace) = settings.namespace.as_ref() {
924                args.push(format!("--namespace={namespace}"));
925            }
926            args.push("exec".to_string());
927            args.push("-i".to_string());
928            args.push(settings.target.clone());
929            if let Some(container) = settings.pod_container.as_ref() {
930                args.push("-c".to_string());
931                args.push(container.clone());
932            }
933            args.push("--".to_string());
934            args.extend(command);
935        }
936    }
937    Ok(args)
938}
939
940fn is_docker_compose_runtime(runtime: &str) -> bool {
941    runtime
942        .rsplit(['/', '\\'])
943        .next()
944        .is_some_and(|name| name == "docker-compose")
945}
946
947fn bridge_command_args(target: &ContainerTarget, nonce: &str) -> Vec<String> {
948    let mut args = vec![
949        "sh".to_string(),
950        "-c".to_string(),
951        shell_bridge_script(nonce),
952        "afpsql-container-bridge".to_string(),
953    ];
954    match target {
955        ContainerTarget::Tcp { host, port } => {
956            args.push("tcp".to_string());
957            args.push(host.clone());
958            args.push(port.to_string());
959        }
960        ContainerTarget::UnixSocket { path } => {
961            args.push("unix".to_string());
962            args.push(path.clone());
963        }
964    }
965    args
966}
967
968fn shell_bridge_script(nonce: &str) -> String {
969    format!(
970        "AFPSQL_BRIDGE_NONCE={}; AFPSQL_CONTAINER_PY_BRIDGE={}; AFPSQL_CONTAINER_PERL_BRIDGE={}; {}",
971        shell_quote(nonce),
972        shell_quote(PYTHON_BRIDGE),
973        shell_quote(PERL_BRIDGE),
974        SHELL_BRIDGE_BODY
975    )
976}
977
978fn build_bridge_ssh_args(
979    settings: &ContainerSettings,
980    target: &ContainerTarget,
981    nonce: &str,
982) -> Result<Vec<String>, String> {
983    let mut args = vec![
984        "-T".to_string(),
985        "-o".to_string(),
986        "BatchMode=yes".to_string(),
987    ];
988    for option in &settings.ssh_options {
989        args.push("-o".to_string());
990        args.push(option.clone());
991    }
992    if let Some(destination) = settings.ssh_destination.as_ref() {
993        args.push(destination.clone());
994    }
995    args.push(remote_container_command(settings, target, nonce)?);
996    Ok(args)
997}
998
999fn remote_container_command(
1000    settings: &ContainerSettings,
1001    target: &ContainerTarget,
1002    nonce: &str,
1003) -> Result<String, String> {
1004    Ok(std::iter::once(settings.runtime.clone())
1005        .chain(build_container_exec_args(settings, target, nonce)?)
1006        .map(|arg| shell_quote(&arg))
1007        .collect::<Vec<_>>()
1008        .join(" "))
1009}
1010
1011fn shell_quote(value: &str) -> String {
1012    if value.is_empty() {
1013        return "''".to_string();
1014    }
1015    if value.bytes().all(|b| {
1016        b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'/' | b':' | b'@' | b'=')
1017    }) {
1018        return value.to_string();
1019    }
1020    format!("'{}'", value.replace('\'', "'\\''"))
1021}
1022
1023fn env_nonempty(name: &str) -> Option<String> {
1024    std::env::var(name).ok().filter(|value| !value.is_empty())
1025}
1026
1027fn env_colon_list(name: &str) -> Vec<String> {
1028    std::env::var(name)
1029        .ok()
1030        .map(|value| {
1031            value
1032                .split(':')
1033                .filter(|part| !part.is_empty())
1034                .map(std::string::ToString::to_string)
1035                .collect()
1036        })
1037        .unwrap_or_default()
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042    use super::*;
1043
1044    const TEST_NONCE: &str = "deadbeefcafef00d";
1045
1046    fn settings(driver: ContainerDriver) -> ContainerSettings {
1047        ContainerSettings {
1048            driver,
1049            runtime: driver.default_runtime().to_string(),
1050            target: "pg".to_string(),
1051            user: Some("postgres".to_string()),
1052            namespace: None,
1053            context: None,
1054            compose_files: vec![],
1055            compose_project: None,
1056            pod_container: None,
1057            ssh_destination: None,
1058            ssh_options: vec![],
1059        }
1060    }
1061
1062    #[test]
1063    fn typed_drivers_select_fixed_default_runtimes() {
1064        for (driver, runtime) in [
1065            (ContainerDriver::Docker, "docker"),
1066            (ContainerDriver::Podman, "podman"),
1067            (ContainerDriver::Nerdctl, "nerdctl"),
1068            (ContainerDriver::Compose, "docker"),
1069            (ContainerDriver::Kubectl, "kubectl"),
1070        ] {
1071            assert_eq!(driver.default_runtime(), runtime);
1072        }
1073    }
1074
1075    #[test]
1076    fn missing_target_detection() {
1077        assert!(stderr_indicates_missing_target(
1078            "Error response from daemon: No such container: pg-typo"
1079        ));
1080        assert!(stderr_indicates_missing_target("container is not running"));
1081        assert!(!stderr_indicates_missing_target(
1082            "sh: python3: not found in PATH"
1083        ));
1084    }
1085
1086    #[test]
1087    fn bridge_args_target_tcp_for_container_cli_like_driver() -> Result<(), String> {
1088        let args = build_container_exec_args(
1089            &settings(ContainerDriver::Docker),
1090            &ContainerTarget::Tcp {
1091                host: "127.0.0.1".to_string(),
1092                port: 5432,
1093            },
1094            TEST_NONCE,
1095        )?;
1096        assert_eq!(args[0], "exec");
1097        assert!(args.contains(&"-i".to_string()));
1098        assert!(args.contains(&"--user".to_string()));
1099        assert!(args.contains(&"postgres".to_string()));
1100        assert!(args.contains(&"pg".to_string()));
1101        assert!(args.contains(&"tcp".to_string()));
1102        assert!(args.contains(&"127.0.0.1".to_string()));
1103        assert!(args.contains(&"5432".to_string()));
1104        assert!(!args.contains(&"-e".to_string()));
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn bridge_args_target_unix_socket() -> Result<(), String> {
1110        let args = build_container_exec_args(
1111            &ContainerSettings {
1112                user: None,
1113                ..settings(ContainerDriver::Docker)
1114            },
1115            &ContainerTarget::UnixSocket {
1116                path: "/var/run/postgresql/.s.PGSQL.5432".to_string(),
1117            },
1118            TEST_NONCE,
1119        )?;
1120        assert!(args.contains(&"unix".to_string()));
1121        assert!(args.contains(&"/var/run/postgresql/.s.PGSQL.5432".to_string()));
1122        Ok(())
1123    }
1124
1125    #[test]
1126    fn compose_driver_uses_compose_exec_without_tty() -> Result<(), String> {
1127        let settings = ContainerSettings {
1128            compose_files: vec!["compose.yml".to_string(), "compose.prod.yml".to_string()],
1129            compose_project: Some("demo".to_string()),
1130            ..settings(ContainerDriver::Compose)
1131        };
1132        let (program, args) = build_bridge_process(
1133            &settings,
1134            &ContainerTarget::Tcp {
1135                host: "db".to_string(),
1136                port: 5432,
1137            },
1138            TEST_NONCE,
1139        )?;
1140        assert_eq!(program, "docker");
1141        assert_eq!(
1142            &args[0..8],
1143            [
1144                "compose",
1145                "-f",
1146                "compose.yml",
1147                "-f",
1148                "compose.prod.yml",
1149                "-p",
1150                "demo",
1151                "exec"
1152            ]
1153        );
1154        assert_eq!(args[8], "-T");
1155        assert!(args.contains(&"pg".to_string()));
1156        Ok(())
1157    }
1158
1159    #[test]
1160    fn compose_runtime_docker_compose_skips_subcommand_prefix() -> Result<(), String> {
1161        let settings = ContainerSettings {
1162            runtime: "/usr/local/bin/docker-compose".to_string(),
1163            ..settings(ContainerDriver::Compose)
1164        };
1165        let (program, args) = build_bridge_process(
1166            &settings,
1167            &ContainerTarget::Tcp {
1168                host: "db".to_string(),
1169                port: 5432,
1170            },
1171            TEST_NONCE,
1172        )?;
1173        assert_eq!(program, "/usr/local/bin/docker-compose");
1174        assert_eq!(&args[0..2], ["exec", "-T"]);
1175        Ok(())
1176    }
1177
1178    #[test]
1179    fn kubectl_driver_uses_exec_separator() -> Result<(), String> {
1180        let settings = ContainerSettings {
1181            user: None,
1182            namespace: Some("prod".to_string()),
1183            context: Some("cluster-a".to_string()),
1184            ..settings(ContainerDriver::Kubectl)
1185        };
1186        let (program, args) = build_bridge_process(
1187            &settings,
1188            &ContainerTarget::Tcp {
1189                host: "127.0.0.1".to_string(),
1190                port: 5432,
1191            },
1192            TEST_NONCE,
1193        )?;
1194        assert_eq!(program, "kubectl");
1195        assert_eq!(
1196            &args[0..6],
1197            [
1198                "--context=cluster-a",
1199                "--namespace=prod",
1200                "exec",
1201                "-i",
1202                "pg",
1203                "--"
1204            ]
1205        );
1206        assert!(args.contains(&"sh".to_string()));
1207        Ok(())
1208    }
1209
1210    #[test]
1211    fn kubectl_driver_inserts_pod_container_before_separator() -> Result<(), String> {
1212        let settings = ContainerSettings {
1213            user: None,
1214            pod_container: Some("postgres".to_string()),
1215            ..settings(ContainerDriver::Kubectl)
1216        };
1217        let (_, args) = build_bridge_process(
1218            &settings,
1219            &ContainerTarget::Tcp {
1220                host: "127.0.0.1".to_string(),
1221                port: 5432,
1222            },
1223            TEST_NONCE,
1224        )?;
1225        assert_eq!(
1226            &args[0..7],
1227            ["exec", "-i", "pg", "-c", "postgres", "--", "sh"]
1228        );
1229        Ok(())
1230    }
1231
1232    #[test]
1233    fn bridge_banner_line_is_not_captured_as_diagnostic() {
1234        let capture = Arc::new(Mutex::new(Vec::new()));
1235        let (tx, mut rx) = oneshot::channel();
1236        let mut tx = Some(tx);
1237        let line = format!("AFPSQL_BRIDGE_OK {TEST_NONCE}\n").into_bytes();
1238        handle_stderr_line(line, &capture, &mut tx, TEST_NONCE);
1239        assert!(matches!(rx.try_recv(), Ok(true)));
1240        assert!(captured_stderr(&capture).is_empty());
1241    }
1242
1243    #[test]
1244    fn bridge_banner_without_matching_nonce_is_treated_as_diagnostic() {
1245        let capture = Arc::new(Mutex::new(Vec::new()));
1246        let (tx, mut rx) = oneshot::channel();
1247        let mut tx = Some(tx);
1248        let line = b"AFPSQL_BRIDGE_OK 0000000000000000\n".to_vec();
1249        handle_stderr_line(line, &capture, &mut tx, TEST_NONCE);
1250        assert!(rx.try_recv().is_err());
1251        assert!(captured_stderr(&capture).contains("AFPSQL_BRIDGE_OK"));
1252    }
1253
1254    #[test]
1255    fn bridge_banner_without_nonce_is_treated_as_diagnostic() {
1256        let capture = Arc::new(Mutex::new(Vec::new()));
1257        let (tx, mut rx) = oneshot::channel();
1258        let mut tx = Some(tx);
1259        handle_stderr_line(
1260            b"AFPSQL_BRIDGE_OK\n".to_vec(),
1261            &capture,
1262            &mut tx,
1263            TEST_NONCE,
1264        );
1265        assert!(rx.try_recv().is_err());
1266        assert!(captured_stderr(&capture).contains("AFPSQL_BRIDGE_OK"));
1267    }
1268
1269    #[test]
1270    fn sanitize_diagnostic_strips_control_chars_and_truncates() {
1271        let input = b"\x1b[31mboom\x1b[0m\nnext line\x00trailing";
1272        let cleaned = sanitize_diagnostic(input);
1273        assert!(!cleaned.contains('\x1b'));
1274        assert!(!cleaned.contains('\x00'));
1275        assert!(cleaned.contains("boom"));
1276        assert!(cleaned.contains("next line"));
1277
1278        let big = vec![b'A'; 4096];
1279        let cleaned = sanitize_diagnostic(&big);
1280        assert!(cleaned.len() <= STDERR_HINT_BYTES);
1281    }
1282
1283    #[test]
1284    fn bridge_process_uses_remote_ssh_container_command() -> Result<(), String> {
1285        let settings = ContainerSettings {
1286            driver: ContainerDriver::Podman,
1287            runtime: "podman".to_string(),
1288            target: "pg remote".to_string(),
1289            user: Some("postgres".to_string()),
1290            namespace: None,
1291            context: None,
1292            compose_files: vec![],
1293            compose_project: None,
1294            pod_container: None,
1295            ssh_destination: Some("root@example.com".to_string()),
1296            ssh_options: vec!["ProxyJump=bastion".to_string()],
1297        };
1298        let (program, args) = build_bridge_process(
1299            &settings,
1300            &ContainerTarget::Tcp {
1301                host: "host.containers.internal".to_string(),
1302                port: 5432,
1303            },
1304            TEST_NONCE,
1305        )?;
1306
1307        assert_eq!(program, "ssh");
1308        assert!(args.contains(&"BatchMode=yes".to_string()));
1309        assert!(args.contains(&"ProxyJump=bastion".to_string()));
1310        assert_eq!(
1311            args.iter().rev().nth(1),
1312            Some(&"root@example.com".to_string())
1313        );
1314
1315        let command = args.last().cloned().unwrap_or_default();
1316        assert!(command.starts_with("podman exec -i "));
1317        assert!(command.contains("'pg remote'"));
1318        assert!(command.contains("host.containers.internal"));
1319        Ok(())
1320    }
1321
1322    #[test]
1323    fn remote_container_command_quotes_shell_torture_values() -> Result<(), String> {
1324        let target = "pg 'quoted' \"$USER\" `whoami`\nnext".to_string();
1325        let user = "postgres '$HOME' `id`\nnext".to_string();
1326        let context = "ctx '$VAR' `cmd`\nnext".to_string();
1327        let settings = ContainerSettings {
1328            driver: ContainerDriver::Docker,
1329            runtime: "docker".to_string(),
1330            target: target.clone(),
1331            user: Some(user.clone()),
1332            namespace: None,
1333            context: Some(context.clone()),
1334            compose_files: vec![],
1335            compose_project: None,
1336            pod_container: None,
1337            ssh_destination: Some("root@example.com".to_string()),
1338            ssh_options: vec![],
1339        };
1340
1341        let command = remote_container_command(
1342            &settings,
1343            &ContainerTarget::Tcp {
1344                host: "127.0.0.1".to_string(),
1345                port: 5432,
1346            },
1347            TEST_NONCE,
1348        )?;
1349
1350        assert!(command.contains(&shell_quote(&format!("--context={context}"))));
1351        assert!(command.contains(&shell_quote(&target)));
1352        assert!(command.contains(&shell_quote(&user)));
1353        Ok(())
1354    }
1355
1356    #[test]
1357    fn target_from_dsn_tcp() -> Result<(), String> {
1358        let cfg = SessionConfig {
1359            container: crate::types::ContainerConfig {
1360                target: Some("pg".to_string()),
1361                ..Default::default()
1362            },
1363            dsn_secret: Some("postgresql://u:p@127.0.0.1:6543/db".to_string()),
1364            ..Default::default()
1365        };
1366        assert_eq!(
1367            resolve_container_target(&cfg)?,
1368            ContainerTarget::Tcp {
1369                host: "127.0.0.1".to_string(),
1370                port: 6543,
1371            }
1372        );
1373        Ok(())
1374    }
1375
1376    #[test]
1377    fn target_from_unix_socket_dir() -> Result<(), String> {
1378        let cfg = SessionConfig {
1379            container: crate::types::ContainerConfig {
1380                target: Some("pg".to_string()),
1381                ..Default::default()
1382            },
1383            host: Some("/var/run/postgresql".to_string()),
1384            port: Some(5433),
1385            ..Default::default()
1386        };
1387        assert_eq!(
1388            resolve_container_target(&cfg)?,
1389            ContainerTarget::UnixSocket {
1390                path: "/var/run/postgresql/.s.PGSQL.5433".to_string(),
1391            }
1392        );
1393        Ok(())
1394    }
1395
1396    #[test]
1397    fn settings_require_container_when_options_set() {
1398        let cfg = SessionConfig {
1399            container: crate::types::ContainerConfig {
1400                user: Some("postgres".to_string()),
1401                ..Default::default()
1402            },
1403            ..Default::default()
1404        };
1405        let err = resolve_container_settings(&cfg);
1406        assert!(matches!(err, Err(message) if message.contains("--container is required")));
1407    }
1408
1409    #[test]
1410    fn settings_require_ssh_destination_when_ssh_options_set() {
1411        let cfg = SessionConfig {
1412            container: crate::types::ContainerConfig {
1413                target: Some("pg".to_string()),
1414                ..Default::default()
1415            },
1416            ssh: crate::types::SshConfig {
1417                options: vec!["ProxyJump=bastion".to_string()],
1418                ..Default::default()
1419            },
1420            ..Default::default()
1421        };
1422        let err = resolve_container_settings(&cfg);
1423        assert!(matches!(err, Err(message) if message.contains("--ssh is required")));
1424    }
1425
1426    #[test]
1427    fn settings_reject_tunnel_only_ssh_options_for_container_transport() {
1428        let cfg = SessionConfig {
1429            container: crate::types::ContainerConfig {
1430                target: Some("pg".to_string()),
1431                ..Default::default()
1432            },
1433            ssh: crate::types::SshConfig {
1434                destination: Some("user@example.com".to_string()),
1435                local_port: Some(15432),
1436                ..Default::default()
1437            },
1438            ..Default::default()
1439        };
1440        let err = resolve_container_settings(&cfg);
1441        assert!(matches!(err, Err(message) if message.contains("supports only --ssh")));
1442    }
1443
1444    #[test]
1445    fn settings_reject_kubectl_user() {
1446        let cfg = SessionConfig {
1447            container: crate::types::ContainerConfig {
1448                target: Some("pod/app".to_string()),
1449                driver: Some("kubectl".to_string()),
1450                user: Some("postgres".to_string()),
1451                ..Default::default()
1452            },
1453            ..Default::default()
1454        };
1455        let err = resolve_container_settings(&cfg);
1456        assert!(matches!(err, Err(message) if message.contains("not supported")));
1457    }
1458
1459    #[test]
1460    fn settings_reject_scoped_flags_for_wrong_driver() {
1461        let cfg = SessionConfig {
1462            container: crate::types::ContainerConfig {
1463                target: Some("pg".to_string()),
1464                driver: Some("podman".to_string()),
1465                context: Some("prod".to_string()),
1466                ..Default::default()
1467            },
1468            ..Default::default()
1469        };
1470        let err = resolve_container_settings(&cfg);
1471        assert!(matches!(err, Err(message) if message.contains("--container-context requires")));
1472    }
1473
1474    #[test]
1475    fn settings_accept_compose_file_env_fallback() {
1476        let _guard = crate::test_env::env_lock();
1477        let old = std::env::var("AFPSQL_CONTAINER_COMPOSE_FILE").ok();
1478        // SAFETY: this test module's environment lock is held for the mutation.
1479        unsafe {
1480            std::env::set_var("AFPSQL_CONTAINER_COMPOSE_FILE", "base.yml:prod.yml");
1481        }
1482        let cfg = SessionConfig {
1483            container: crate::types::ContainerConfig {
1484                target: Some("pg".to_string()),
1485                driver: Some("compose".to_string()),
1486                ..Default::default()
1487            },
1488            ..Default::default()
1489        };
1490        let settings = resolve_container_settings(&cfg);
1491        match old {
1492            // SAFETY: this test module's environment lock is still held here.
1493            Some(value) => unsafe { std::env::set_var("AFPSQL_CONTAINER_COMPOSE_FILE", value) },
1494            // SAFETY: this test module's environment lock is still held here.
1495            None => unsafe { std::env::remove_var("AFPSQL_CONTAINER_COMPOSE_FILE") },
1496        }
1497        assert!(matches!(
1498            settings,
1499            Ok(ContainerSettings {
1500                compose_files,
1501                ..
1502            }) if compose_files == vec!["base.yml".to_string(), "prod.yml".to_string()]
1503        ));
1504    }
1505
1506    #[test]
1507    fn container_bridge_hint_classifies_interpreter_failure() {
1508        let hint = container_bridge_hint(
1509            None,
1510            "afpsql container bridge requires python3, python, or perl in the container",
1511        )
1512        .unwrap_or_default();
1513        assert!(hint.contains("no supported interpreter"));
1514        assert!(hint.contains("container bridge stderr"));
1515    }
1516}