9fecd57a99 2026-10-28 1: //! The entry point is private to the binary, so test its public CLI and SMTP
9fecd57a99 2026-10-28 2: //! behavior with isolated configuration directories and bounded subprocesses.
9fecd57a99 2026-10-28 3: use std::{
9fecd57a99 2026-10-28 4: fs::{self, OpenOptions},
9fecd57a99 2026-10-28 5: io::{BufRead, BufReader, Write},
9fecd57a99 2026-10-28 6: net::{SocketAddr, TcpListener, TcpStream},
9fecd57a99 2026-10-28 7: os::unix::fs::{OpenOptionsExt, PermissionsExt},
9fecd57a99 2026-10-28 8: path::PathBuf,
9fecd57a99 2026-10-28 9: process::{Child, Command, Output, Stdio},
9fecd57a99 2026-10-28 10: sync::atomic::{AtomicUsize, Ordering},
9fecd57a99 2026-10-28 11: thread,
9fecd57a99 2026-10-28 12: time::{Duration, Instant},
9fecd57a99 2026-10-28 13: };
9fecd57a99 2026-10-28 14: const DEADLINE: Duration = Duration::from_secs(5);
9fecd57a99 2026-10-28 15: struct TestDir(PathBuf);
9fecd57a99 2026-10-28 16: impl TestDir {
9fecd57a99 2026-10-28 17: fn new () -> Self {
9fecd57a99 2026-10-28 18: static NEXT: AtomicUsize = AtomicUsize::new(0);
9fecd57a99 2026-10-28 19: loop {
9fecd57a99 2026-10-28 20: let path = std::env::temp_dir().join(format!("smtp2tg-cli-{}-{}",
9fecd57a99 2026-10-28 21: std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
9fecd57a99 2026-10-28 22: match fs::create_dir(&path) {
9fecd57a99 2026-10-28 23: Ok(()) => return Self(path),
9fecd57a99 2026-10-28 24: Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
9fecd57a99 2026-10-28 25: Err(error) => panic!("Cannot create test directory: {error}"),
9fecd57a99 2026-10-28 26: }
9fecd57a99 2026-10-28 27: }
9fecd57a99 2026-10-28 28: }
9fecd57a99 2026-10-28 29: fn config (&self, name: &str, text: &str, mode: u32) {
9fecd57a99 2026-10-28 30: let path = self.0.join(name);
9fecd57a99 2026-10-28 31: let mut file = OpenOptions::new().create_new(true).write(true).mode(0o600)
9fecd57a99 2026-10-28 32: .open(&path).unwrap();
9fecd57a99 2026-10-28 33: file.write_all(text.as_bytes()).unwrap();
9fecd57a99 2026-10-28 34: fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
9fecd57a99 2026-10-28 35: }
9fecd57a99 2026-10-28 36: fn spawn (&self, args: &[&str]) -> Process {
9fecd57a99 2026-10-28 37: Process(Some(Command::new(env!("CARGO_BIN_EXE_smtp2tg"))
9fecd57a99 2026-10-28 38: .current_dir(&self.0).args(args)
9fecd57a99 2026-10-28 39: .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped())
9fecd57a99 2026-10-28 40: .spawn().unwrap()))
9fecd57a99 2026-10-28 41: }
9fecd57a99 2026-10-28 42: fn run (&self, args: &[&str]) -> Output {
9fecd57a99 2026-10-28 43: self.spawn(args).output()
9fecd57a99 2026-10-28 44: }
9fecd57a99 2026-10-28 45: }
9fecd57a99 2026-10-28 46: impl Drop for TestDir {
9fecd57a99 2026-10-28 47: fn drop (&mut self) {
9fecd57a99 2026-10-28 48: let _ = fs::remove_dir_all(&self.0);
9fecd57a99 2026-10-28 49: }
9fecd57a99 2026-10-28 50: }
9fecd57a99 2026-10-28 51: struct Process(Option<Child>);
9fecd57a99 2026-10-28 52: impl Process {
9fecd57a99 2026-10-28 53: fn output (mut self) -> Output {
9fecd57a99 2026-10-28 54: let deadline = Instant::now() + DEADLINE;
9fecd57a99 2026-10-28 55: while self.0.as_mut().unwrap().try_wait().unwrap().is_none() {
9fecd57a99 2026-10-28 56: assert!(Instant::now() < deadline, "CLI did not exit within {DEADLINE:?}");
9fecd57a99 2026-10-28 57: thread::sleep(Duration::from_millis(10));
9fecd57a99 2026-10-28 58: }
9fecd57a99 2026-10-28 59: self.0.take().unwrap().wait_with_output().unwrap()
9fecd57a99 2026-10-28 60: }
9fecd57a99 2026-10-28 61: fn connect (&mut self, address: SocketAddr) -> TcpStream {
9fecd57a99 2026-10-28 62: let deadline = Instant::now() + DEADLINE;
9fecd57a99 2026-10-28 63: loop {
9fecd57a99 2026-10-28 64: assert!(self.0.as_mut().unwrap().try_wait().unwrap().is_none(),
9fecd57a99 2026-10-28 65: "SMTP server exited before accepting connections");
9fecd57a99 2026-10-28 66: if let Ok(stream) = TcpStream::connect_timeout(&address, Duration::from_millis(100)) {
9fecd57a99 2026-10-28 67: stream.set_read_timeout(Some(DEADLINE)).unwrap();
9fecd57a99 2026-10-28 68: stream.set_write_timeout(Some(DEADLINE)).unwrap();
9fecd57a99 2026-10-28 69: return stream;
9fecd57a99 2026-10-28 70: }
9fecd57a99 2026-10-28 71: assert!(Instant::now() < deadline, "SMTP server did not start within {DEADLINE:?}");
9fecd57a99 2026-10-28 72: thread::sleep(Duration::from_millis(10));
9fecd57a99 2026-10-28 73: }
9fecd57a99 2026-10-28 74: }
9fecd57a99 2026-10-28 75: }
9fecd57a99 2026-10-28 76: impl Drop for Process {
9fecd57a99 2026-10-28 77: fn drop (&mut self) {
9fecd57a99 2026-10-28 78: if let Some(child) = &mut self.0 {
9fecd57a99 2026-10-28 79: let _ = child.kill();
9fecd57a99 2026-10-28 80: let _ = child.wait();
9fecd57a99 2026-10-28 81: }
9fecd57a99 2026-10-28 82: }
9fecd57a99 2026-10-28 83: }
9fecd57a99 2026-10-28 84: fn assert_error (output: Output, expected: &str) {
9fecd57a99 2026-10-28 85: let stderr = String::from_utf8_lossy(&output.stderr);
9fecd57a99 2026-10-28 86: assert!(!output.status.success(), "Expected failure, stderr: {stderr}");
9fecd57a99 2026-10-28 87: assert!(stderr.contains(expected), "Missing {expected:?} in stderr: {stderr}");
9fecd57a99 2026-10-28 88: }
9fecd57a99 2026-10-28 89: #[test]
9fecd57a99 2026-10-28 90: fn help_succeeds_without_a_configuration_file () {
9fecd57a99 2026-10-28 91: let dir = TestDir::new();
9fecd57a99 2026-10-28 92: for flag in ["-h", "--help"] {
9fecd57a99 2026-10-28 93: let output = dir.run(&[flag]);
9fecd57a99 2026-10-28 94: assert!(output.status.success());
9fecd57a99 2026-10-28 95: let help = String::from_utf8(output.stdout).unwrap();
9fecd57a99 2026-10-28 96: for expected in ["SMTP-to-Telegram gateway", env!("CARGO_PKG_VERSION"),
9fecd57a99 2026-10-28 97: "-c, --config", "[default: smtp2tg.toml]"] {
9fecd57a99 2026-10-28 98: assert!(help.contains(expected), "Missing {expected:?} in help: {help}");
9fecd57a99 2026-10-28 99: }
9fecd57a99 2026-10-28 100: }
9fecd57a99 2026-10-28 101: }
9fecd57a99 2026-10-28 102: #[test]
9fecd57a99 2026-10-28 103: fn missing_default_and_explicit_configuration_paths_are_reported () {
9fecd57a99 2026-10-28 104: let dir = TestDir::new();
9fecd57a99 2026-10-28 105: for (args, path) in [
9fecd57a99 2026-10-28 106: (vec![], "smtp2tg.toml"),
9fecd57a99 2026-10-28 107: (vec!["-c", "missing file.toml"], "missing file.toml"),
9fecd57a99 2026-10-28 108: (vec!["--config", "missing file.toml"], "missing file.toml"),
9fecd57a99 2026-10-28 109: ] {
9fecd57a99 2026-10-28 110: let output = dir.run(&args);
9fecd57a99 2026-10-28 111: let stderr = String::from_utf8_lossy(&output.stderr);
9fecd57a99 2026-10-28 112: assert!(stderr.contains(path), "Missing path in diagnostic: {stderr}");
9fecd57a99 2026-10-28 113: assert_error(output, "Configuration file not found");
9fecd57a99 2026-10-28 114: }
9fecd57a99 2026-10-28 115: }
9fecd57a99 2026-10-28 116: #[test]
9fecd57a99 2026-10-28 117: fn invalid_arguments_fail_before_configuration_loading () {
9fecd57a99 2026-10-28 118: let dir = TestDir::new();
9fecd57a99 2026-10-28 119: for args in [vec!["-c"], vec!["--config"], vec!["--unknown-option"]] {
9fecd57a99 2026-10-28 120: let output = dir.run(&args);
9fecd57a99 2026-10-28 121: assert_eq!(output.status.code(), Some(2), "arguments: {args:?}");
9fecd57a99 2026-10-28 122: assert!(!String::from_utf8_lossy(&output.stderr).contains("Configuration file not found"));
9fecd57a99 2026-10-28 123: }
9fecd57a99 2026-10-28 124: }
9fecd57a99 2026-10-28 125: #[test]
9fecd57a99 2026-10-28 126: fn insecure_permissions_are_rejected_before_parsing () {
9fecd57a99 2026-10-28 127: // Each forbidden permission is independent: group read/write/execute,
9fecd57a99 2026-10-28 128: // other read/write/execute, and owner execute.
9fecd57a99 2026-10-28 129: for mode in [0o640, 0o620, 0o610, 0o604, 0o602, 0o601, 0o700] {
9fecd57a99 2026-10-28 130: let dir = TestDir::new();
9fecd57a99 2026-10-28 131: dir.config("smtp2tg.toml", "this is not valid TOML", mode);
9fecd57a99 2026-10-28 132: let output = dir.run(&[]);
9fecd57a99 2026-10-28 133: let stderr = String::from_utf8_lossy(&output.stderr);
9fecd57a99 2026-10-28 134: assert!(stderr.contains("Required: 0600"), "mode {mode:o}: {stderr}");
9fecd57a99 2026-10-28 135: assert!(stderr.contains("chmod 600"), "mode {mode:o}: {stderr}");
9fecd57a99 2026-10-28 136: assert_error(output, "Configuration file permissions are insecure");
9fecd57a99 2026-10-28 137: }
9fecd57a99 2026-10-28 138: }
9fecd57a99 2026-10-28 139: #[test]
9fecd57a99 2026-10-28 140: fn secure_malformed_config_reports_parse_context () {
9fecd57a99 2026-10-28 141: let dir = TestDir::new();
9fecd57a99 2026-10-28 142: // A spaces-containing path must be passed intact, and the selected file
9fecd57a99 2026-10-28 143: // must take precedence over an insecure default configuration.
9fecd57a99 2026-10-28 144: dir.config("smtp2tg.toml", "ignored", 0o644);
9fecd57a99 2026-10-28 145: dir.config("selected config.toml", "api_key = [", 0o600);
9fecd57a99 2026-10-28 146: for flag in ["-c", "--config"] {
9fecd57a99 2026-10-28 147: let output = dir.run(&[flag, "selected config.toml"]);
9fecd57a99 2026-10-28 148: let stderr = String::from_utf8_lossy(&output.stderr);
9fecd57a99 2026-10-28 149: assert!(stderr.contains("selected config.toml"));
9fecd57a99 2026-10-28 150: assert!(stderr.contains("smtp2tg.toml.example"));
9fecd57a99 2026-10-28 151: assert_error(output, "Failed to parse configuration file");
9fecd57a99 2026-10-28 152: }
9fecd57a99 2026-10-28 153: }
9fecd57a99 2026-10-28 154: #[test]
9fecd57a99 2026-10-28 155: fn required_settings_remain_required_after_applying_defaults () {
9fecd57a99 2026-10-28 156: for (config, expected) in [
9fecd57a99 2026-10-28 157: ("default = 0\n[recipients]\n", "api_key"),
9fecd57a99 2026-10-28 158: ("api_key = 'test-api-key'\n[recipients]\n", "default"),
9fecd57a99 2026-10-28 159: ("api_key = 'test-api-key'\ndefault = 0\n", "recipients"),
9fecd57a99 2026-10-28 160: ] {
9fecd57a99 2026-10-28 161: let dir = TestDir::new();
9fecd57a99 2026-10-28 162: dir.config("smtp2tg.toml", config, 0o600);
9fecd57a99 2026-10-28 163: let output = dir.run(&[]);
9fecd57a99 2026-10-28 164: assert_error(output, expected);
9fecd57a99 2026-10-28 165: }
9fecd57a99 2026-10-28 166: }
9fecd57a99 2026-10-28 167: fn smtp_reply (stream: &mut BufReader<TcpStream>) -> String {
9fecd57a99 2026-10-28 168: let mut response = String::new();
9fecd57a99 2026-10-28 169: loop {
9fecd57a99 2026-10-28 170: let mut line = String::new();
9fecd57a99 2026-10-28 171: assert!(stream.read_line(&mut line).unwrap() > 0, "SMTP connection closed: {response}");
9fecd57a99 2026-10-28 172: let more = line.as_bytes().get(3) == Some(&b'-');
9fecd57a99 2026-10-28 173: response.push_str(&line);
9fecd57a99 2026-10-28 174: if !more {
9fecd57a99 2026-10-28 175: return response;
9fecd57a99 2026-10-28 176: }
9fecd57a99 2026-10-28 177: }
9fecd57a99 2026-10-28 178: }
9fecd57a99 2026-10-28 179: #[test]
9fecd57a99 2026-10-28 180: fn entry_point_serves_smtp_with_defaults_and_configuration_overrides () {
9fecd57a99 2026-10-28 181: for (overrides, hostname, allowed, denied) in [
9fecd57a99 2026-10-28 182: ("", "smtp.2.tg", "localhost", "outside.invalid"),
9fecd57a99 2026-10-28 183: ("hostname = 'custom.smtp'\ndomains = ['example.com']\nfields = []\n",
9fecd57a99 2026-10-28 184: "custom.smtp", "example.com", "localhost"),
9fecd57a99 2026-10-28 185: ] {
9fecd57a99 2026-10-28 186: let dir = TestDir::new();
9fecd57a99 2026-10-28 187: let reservation = TcpListener::bind("127.0.0.1:0").unwrap();
9fecd57a99 2026-10-28 188: let address = reservation.local_addr().unwrap();
9fecd57a99 2026-10-28 189: dir.config("smtp2tg.toml", &format!(
9fecd57a99 2026-10-28 190: "api_key = 'test-api-key'\ndefault = 0\nlisten_on = '{address}'\n{overrides}[recipients]\n"), 0o600);
9fecd57a99 2026-10-28 191: drop(reservation);
9fecd57a99 2026-10-28 192: let mut process = dir.spawn(&[]);
9fecd57a99 2026-10-28 193: let mut stream = BufReader::new(process.connect(address));
9fecd57a99 2026-10-28 194: let banner = smtp_reply(&mut stream);
9fecd57a99 2026-10-28 195: assert!(banner.starts_with("220 "), "{banner}");
9fecd57a99 2026-10-28 196: assert!(banner.contains(hostname), "{banner}");
9fecd57a99 2026-10-28 197: for (command, code) in [
9fecd57a99 2026-10-28 198: ("EHLO test.local\r\n".into(), "250"),
9fecd57a99 2026-10-28 199: ("MAIL FROM:<sender@example.org>\r\n".into(), "250"),
9fecd57a99 2026-10-28 200: (format!("RCPT TO:<user@{allowed}>\r\n"), "250"),
9fecd57a99 2026-10-28 201: (format!("RCPT TO:<user@{denied}>\r\n"), "550"),
9fecd57a99 2026-10-28 202: ("QUIT\r\n".into(), "221"),
9fecd57a99 2026-10-28 203: ] {
9fecd57a99 2026-10-28 204: stream.get_mut().write_all(command.as_bytes()).unwrap();
9fecd57a99 2026-10-28 205: let reply = smtp_reply(&mut stream);
9fecd57a99 2026-10-28 206: assert!(reply.starts_with(code), "command {command:?}: {reply}");
9fecd57a99 2026-10-28 207: }
9fecd57a99 2026-10-28 208: }
9fecd57a99 2026-10-28 209: }