Skip to main content

agent_first_psql/
container_transport.rs

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