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