Annotation For tests/cli.rs
Logged in as anonymous

Origin for each line in tests/cli.rs from check-in 9fecd57a99:

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: }