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 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 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
559const CONTAINER_LIST_TIMEOUT: Duration = Duration::from_secs(3);
561
562async 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 args.push("ps".to_string());
579 args.push("--format".to_string());
580 args.push("{{.Names}}".to_string());
581 }
582 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 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 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 Some(value) => unsafe { std::env::set_var("AFPSQL_CONTAINER_COMPOSE_FILE", value) },
1481 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}