tests.rs (20212B)
1 //! Agate integration tests 2 //! 3 //! This program is free software: you can redistribute it and/or modify 4 //! it under the terms of the GNU General Public License as published by 5 //! the Free Software Foundation, either version 3 of the License, or 6 //! (at your option) any later version. 7 //! 8 //! This program is distributed in the hope that it will be useful, 9 //! but WITHOUT ANY WARRANTY; without even the implied warranty of 10 //! MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 //! GNU General Public License for more details. 12 //! 13 //! You should have received a copy of the GNU General Public License 14 //! along with this program. If not, see <https://www.gnu.org/licenses/>. 15 16 use rustls::{pki_types::CertificateDer, ClientConnection, RootCertStore}; 17 use std::convert::TryInto; 18 use std::io::{BufRead, BufReader, Read, Write}; 19 use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; 20 use std::path::PathBuf; 21 use std::process::{Command, Stdio}; 22 use std::sync::atomic::{AtomicU16, Ordering}; 23 use std::thread::sleep; 24 use std::time::Duration; 25 use tokio_rustls::rustls; 26 use trotter::{Actor, Response, Status}; 27 use url::Url; 28 29 static BINARY_PATH: &str = env!("CARGO_BIN_EXE_agate"); 30 31 static DEFAULT_PORT: u16 = 1965; 32 /// this is our atomic port that increments for each test that needs one 33 /// doing it this way avoids port collisions from manually setting ports 34 static PORT: AtomicU16 = AtomicU16::new(DEFAULT_PORT); 35 36 struct Server { 37 addr: SocketAddr, 38 server: std::process::Child, 39 // is set when output is collected by stop() 40 output: Option<Result<(), String>>, 41 } 42 43 impl Server { 44 pub fn new(args: &[&str]) -> Self { 45 use std::net::{IpAddr, Ipv4Addr}; 46 47 // generate unique port/address so tests do not clash 48 let addr = ( 49 IpAddr::V4(Ipv4Addr::LOCALHOST), 50 PORT.fetch_add(1, Ordering::SeqCst), 51 ) 52 .to_socket_addrs() 53 .unwrap() 54 .next() 55 .unwrap(); 56 57 // start the server 58 let mut server = Command::new(BINARY_PATH) 59 .stderr(Stdio::piped()) 60 .current_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data")) 61 // add address information 62 .args(["--addr", &addr.to_string()]) 63 .args(args) 64 .env("RUST_LOG", "debug") 65 .spawn() 66 .expect("failed to start binary"); 67 68 // We can be sure that agate is listening because it logs a message saying so. 69 let mut reader = BufReader::new(server.stderr.as_mut().unwrap()); 70 let mut buffer = String::new(); 71 while matches!(reader.read_line(&mut buffer), Ok(i) if i>0) { 72 print!("log: {buffer}"); 73 if buffer.contains("Started") { 74 break; 75 } 76 77 buffer.clear(); 78 } 79 80 if matches!(server.try_wait(), Ok(Some(_)) | Err(_)) { 81 panic!("Server did not start properly"); 82 } 83 84 Self { 85 addr, 86 server, 87 output: None, 88 } 89 } 90 91 pub fn get_addr(&self) -> SocketAddr { 92 self.addr 93 } 94 95 pub fn stop(&mut self) -> Result<(), String> { 96 // try to stop the server 97 if let Some(output) = self.output.as_ref() { 98 return output.clone(); 99 } 100 101 self.output = Some(match self.server.try_wait() { 102 Err(e) => Err(format!("cannot access orchestrated program: {e:?}")), 103 Ok(None) => { 104 // everything fine, still running as expected, kill it now 105 self.server.kill().unwrap(); 106 107 let mut reader = BufReader::new(self.server.stderr.as_mut().unwrap()); 108 let mut buffer = String::new(); 109 while matches!(reader.read_line(&mut buffer), Ok(i) if i>0) { 110 print!("log: {buffer}"); 111 if buffer.contains("Listening") { 112 break; 113 } 114 } 115 Ok(()) 116 } 117 Ok(Some(_)) => { 118 let mut reader = BufReader::new(self.server.stderr.as_mut().unwrap()); 119 let mut buffer = String::new(); 120 while matches!(reader.read_line(&mut buffer), Ok(i) if i>0) { 121 print!("log: {buffer}"); 122 if buffer.contains("Listening") { 123 break; 124 } 125 } 126 Err(buffer) 127 } 128 }); 129 self.output.clone().unwrap() 130 } 131 } 132 133 impl Drop for Server { 134 fn drop(&mut self) { 135 if self.output.is_none() && !std::thread::panicking() { 136 // a potential error message was not yet handled 137 self.stop().unwrap(); 138 } else if self.output.is_some() { 139 // server was already stopped 140 } else { 141 // we are panicking and a potential error was not handled 142 self.stop().unwrap_or_else(|e| eprintln!("{e}")); 143 } 144 } 145 } 146 147 fn get(args: &[&str], url: &str) -> Result<Response, String> { 148 let mut server = Server::new(args); 149 150 let url = Url::parse(url).unwrap(); 151 let actor = Actor::default().proxy("localhost".into(), server.addr.port()); 152 let request = actor.get(url); 153 154 let response = tokio::runtime::Runtime::new() 155 .unwrap() 156 .block_on(request) 157 .map_err(|e| e.to_string()); 158 server.stop()?; 159 response 160 } 161 162 #[test] 163 /// - serves index page for a directory 164 /// - serves the correct content 165 fn index_page() { 166 let page = get(&[], "gemini://localhost").expect("could not get page"); 167 168 assert_eq!(page.status, Status::Success.value()); 169 assert_eq!(page.meta, "text/gemini"); 170 171 assert_eq!(page.content, include_bytes!("data/content/index.gmi")); 172 } 173 174 #[cfg(unix)] 175 #[test] 176 fn index_page_unix() { 177 let sock_path = std::env::temp_dir().join("agate-test-unix-socket"); 178 179 // this uses multicert because those certificates are set up so rustls 180 // does not complain about them being CA certificates 181 let mut server = Server::new(&[ 182 "--certs", 183 "multicert", 184 "--socket", 185 sock_path 186 .to_str() 187 .expect("could not convert temp dir path to string"), 188 ]); 189 190 // set up TLS connection via unix socket 191 let mut certs = RootCertStore::empty(); 192 certs 193 .add(CertificateDer::from( 194 include_bytes!("data/multicert/example.com/cert.der").as_slice(), 195 )) 196 .unwrap(); 197 let config = rustls::ClientConfig::builder() 198 .with_root_certificates(certs) 199 .with_no_client_auth(); 200 let mut session = ClientConnection::new( 201 std::sync::Arc::new(config), 202 "example.com".try_into().unwrap(), 203 ) 204 .unwrap(); 205 206 let mut unix = loop { 207 if let Ok(sock) = std::os::unix::net::UnixStream::connect(&sock_path) { 208 break sock; 209 } 210 sleep(Duration::from_millis(10)); 211 }; 212 let mut tls = rustls::Stream::new(&mut session, &mut unix); 213 214 write!(tls, "gemini://example.com\r\n").unwrap(); 215 216 let mut buf = [0; 16]; 217 let _ = tls.read(&mut buf); 218 219 assert_eq!(&buf, b"20 text/gemini\r\n"); 220 221 server.stop().expect("failed to stop server"); 222 } 223 224 #[test] 225 /// - symlinked files are followed correctly 226 fn symlink_page() { 227 let page = get(&[], "gemini://localhost/symlink.gmi").expect("could not get page"); 228 229 assert_eq!(page.status, Status::Success.value()); 230 assert_eq!(page.meta, "text/gemini"); 231 assert_eq!(page.content, include_bytes!("data/content/index.gmi")); 232 } 233 234 #[test] 235 /// - symlinked directories are followed correctly 236 fn symlink_directory() { 237 let page = get(&[], "gemini://localhost/symlinked_dir/file.gmi").expect("could not get page"); 238 239 assert_eq!(page.status, Status::Success.value()); 240 assert_eq!(page.meta, "text/gemini"); 241 assert_eq!(page.content, include_bytes!("data/symlinked_dir/file.gmi")); 242 } 243 244 #[test] 245 /// - the `--addr` configuration works 246 /// - MIME media types can be set in the configuration file 247 fn meta() { 248 let page = get(&[], "gemini://localhost/test").expect("could not get page"); 249 assert_eq!(page.status, Status::Success.value()); 250 assert_eq!(page.meta, "text/html"); 251 } 252 253 #[test] 254 /// - MIME type is correctly guessed for `.gmi` files 255 /// - MIME media type parameters can be set in the configuration file 256 fn meta_param() { 257 let page = get(&[], "gemini://localhost/test.gmi").expect("could not get page"); 258 assert_eq!(page.status, Status::Success.value()); 259 assert_eq!(page.meta, "text/gemini;lang=en ;charset=us-ascii"); 260 } 261 262 #[test] 263 /// - globs in the configuration file work correctly 264 /// - distributed configuration file is used when `-C` flag not used 265 fn glob() { 266 let page = get(&[], "gemini://localhost/testdir/a.nl.gmi").expect("could not get page"); 267 assert_eq!(page.status, Status::Success.value()); 268 assert_eq!(page.meta, "text/plain;lang=nl"); 269 } 270 271 #[test] 272 /// - double globs (i.e. `**`) work correctly in the configuration file 273 /// - central configuration file is used when `-C` flag is used 274 fn doubleglob() { 275 let page = get(&["-C"], "gemini://localhost/testdir/a.nl.gmi").expect("could not get page"); 276 assert_eq!(page.status, Status::Success.value()); 277 assert_eq!(page.meta, "text/gemini;lang=nl"); 278 } 279 280 #[test] 281 /// - full header lines can be set in the configuration file 282 fn full_header_preset() { 283 let page = get(&[], "gemini://localhost/gone.txt").expect("could not get page"); 284 assert_eq!(page.status, Status::Gone.value()); 285 assert_eq!(page.meta, "This file is no longer available."); 286 } 287 288 #[test] 289 /// - URLS with fragments are rejected 290 fn fragment() { 291 let page = get( 292 &["--hostname", "example.com"], 293 "gemini://example.com/#fragment", 294 ) 295 .expect("could not get page"); 296 297 assert_eq!(page.status, Status::BadRequest.value()); 298 } 299 300 #[test] 301 /// - URLS with username are rejected 302 fn username() { 303 let page = get(&["--hostname", "example.com"], "gemini://user@example.com/") 304 .expect("could not get page"); 305 306 assert_eq!(page.status, Status::BadRequest.value()); 307 } 308 309 #[test] 310 /// - URLS with invalid hostnames are rejected 311 fn percent_encode() { 312 // Can't use `get` here because we are testing a URL thats invalid so 313 // the gemini fetching library can not process it. 314 let mut server = Server::new(&["--certs", "multicert"]); 315 316 let mut certs = RootCertStore::empty(); 317 certs 318 .add(CertificateDer::from( 319 include_bytes!("data/multicert/example.com/cert.der").as_slice(), 320 )) 321 .unwrap(); 322 let config = rustls::ClientConfig::builder() 323 .with_root_certificates(certs) 324 .with_no_client_auth(); 325 326 let mut session = ClientConnection::new( 327 std::sync::Arc::new(config), 328 "example.com".try_into().unwrap(), 329 ) 330 .unwrap(); 331 let mut tcp = TcpStream::connect(server.get_addr()).unwrap(); 332 let mut tls = rustls::Stream::new(&mut session, &mut tcp); 333 334 write!(tls, "gemini://%/\r\n").unwrap(); 335 336 let mut buf = [0; 10]; 337 let _ = tls.read(&mut buf); 338 339 assert_eq!(&buf[0..2], b"59"); 340 341 server.stop().unwrap(); 342 } 343 344 #[test] 345 /// - URLS with password are rejected 346 fn password() { 347 let page = get( 348 &["--hostname", "example.com"], 349 "gemini://:secret@example.com/", 350 ) 351 .expect("could not get page"); 352 353 assert_eq!(page.status, Status::BadRequest.value()); 354 } 355 356 #[test] 357 /// - hostname is checked when provided 358 /// - status for wrong host is "proxy request refused" 359 fn hostname_check() { 360 let page = 361 get(&["--hostname", "example.org"], "gemini://example.com/").expect("could not get page"); 362 363 assert_eq!(page.status, Status::ProxyRequestRefused.value()); 364 } 365 366 #[test] 367 /// - port is checked when hostname is provided 368 /// - status for wrong port is "proxy request refused" 369 fn port_check() { 370 let page = 371 get(&["--hostname", "example.org"], "gemini://example.org:1/").expect("could not get page"); 372 373 assert_eq!(page.status, Status::ProxyRequestRefused.value()); 374 } 375 376 #[test] 377 /// - port is not checked if the skip option is passed. 378 fn port_check_skipped() { 379 let page = get( 380 &["--hostname", "example.org", "--skip-port-check"], 381 "gemini://example.org:1/", 382 ) 383 .expect("could not get page"); 384 385 assert_eq!(page.status, Status::Success.value()); 386 } 387 388 #[test] 389 /// - status for paths with hidden segments is "gone" if file does not exist 390 fn secret_nonexistent() { 391 let page = get(&[], "gemini://localhost/.non-existing-secret").expect("could not get page"); 392 393 assert_eq!(page.status, Status::Gone.value()); 394 } 395 396 #[test] 397 /// - status for paths with hidden segments is "gone" if file exists 398 fn secret_exists() { 399 let page = get(&[], "gemini://localhost/.meta").expect("could not get page"); 400 401 assert_eq!(page.status, Status::Gone.value()); 402 } 403 404 #[test] 405 /// - secret file served if `--serve-secret` is enabled 406 fn serve_secret() { 407 let page = get(&["--serve-secret"], "gemini://localhost/.meta").expect("could not get page"); 408 409 assert_eq!(page.status, Status::Success.value()); 410 } 411 412 #[test] 413 /// - secret file served if path is in sidecar 414 fn serve_secret_meta_config() { 415 let page = get(&[], "gemini://localhost/.servable-secret").expect("could not get page"); 416 417 assert_eq!(page.status, Status::Success.value()); 418 } 419 420 #[test] 421 /// - secret file served if path with subdir is in sidecar 422 fn serve_secret_meta_config_subdir() { 423 let page = 424 get(&["-C"], "gemini://localhost/.well-known/servable-secret").expect("could not get page"); 425 426 assert_eq!(page.status, Status::Success.value()); 427 } 428 429 #[test] 430 /// - directory traversal attacks using percent-encoded path separators 431 /// fail (this addresses a previous vulnerability) 432 fn directory_traversal_regression() { 433 let base = Url::parse("gemini://localhost/").unwrap(); 434 435 let mut absolute = base.clone(); 436 absolute 437 .path_segments_mut() 438 .unwrap() 439 .push(env!("CARGO_MANIFEST_DIR")) // separators will be percent-encoded 440 .push("tests") 441 .push("data") 442 .push("directory_traversal.gmi"); 443 444 let mut relative_escape_path = PathBuf::new(); 445 relative_escape_path.push("testdir"); 446 relative_escape_path.push(".."); 447 relative_escape_path.push(".."); 448 let mut relative = base; 449 relative 450 .path_segments_mut() 451 .unwrap() 452 .push(relative_escape_path.to_str().unwrap()) // separators will be percent-encoded 453 .push("directory_traversal.gmi"); 454 455 let urls = [absolute, relative]; 456 for url in urls.iter() { 457 let page = get(&[], url.as_str()).expect("could not get page"); 458 assert_eq!(page.status, Status::NotFound.value()); 459 } 460 } 461 462 #[test] 463 /// - if TLSv1.3 is selected, does not accept TLSv1.2 connections 464 /// (lower versions do not have to be tested because rustls does not even 465 /// support them, making agate incapable of accepting them) 466 fn explicit_tls_version() { 467 let server = Server::new(&["-3"]); 468 469 // try to connect using only TLS 1.2 470 let config = rustls::ClientConfig::builder_with_protocol_versions(&[&rustls::version::TLS12]) 471 .with_root_certificates(RootCertStore::empty()) 472 .with_no_client_auth(); 473 474 let mut session = 475 ClientConnection::new(std::sync::Arc::new(config), "localhost".try_into().unwrap()) 476 .unwrap(); 477 let mut tcp = TcpStream::connect(server.get_addr()).unwrap(); 478 let mut tls = rustls::Stream::new(&mut session, &mut tcp); 479 480 let mut buf = [0; 10]; 481 assert_eq!( 482 *tls.read(&mut buf) 483 .unwrap_err() 484 .into_inner() 485 .unwrap() 486 .downcast::<rustls::Error>() 487 .unwrap(), 488 rustls::Error::AlertReceived(rustls::AlertDescription::ProtocolVersion) 489 ) 490 } 491 492 mod vhosts { 493 use super::*; 494 495 #[test] 496 /// - simple vhosts are enabled when multiple hostnames are supplied 497 /// - the vhosts access the correct files 498 /// - the hostname comparison is case insensitive 499 /// - the hostname is converted to lower case to access certificates 500 fn example_com() { 501 let page = get( 502 &["--hostname", "example.com", "--hostname", "example.org"], 503 "gemini://Example.com/", 504 ) 505 .expect("could not get page"); 506 507 assert_eq!(page.status, Status::Success.value()); 508 assert_eq!( 509 page.content, 510 include_bytes!("data/content/example.com/index.gmi") 511 ); 512 } 513 514 #[test] 515 /// - the vhosts access the correct files 516 fn example_org() { 517 let page = get( 518 &["--hostname", "example.com", "--hostname", "example.org"], 519 "gemini://example.org/", 520 ) 521 .expect("could not get page"); 522 523 assert_eq!(page.status, Status::Success.value()); 524 assert_eq!( 525 page.content, 526 include_bytes!("data/content/example.org/index.gmi") 527 ); 528 } 529 } 530 531 mod multicert { 532 use super::*; 533 use rustls::{pki_types::CertificateDer, ClientConnection, RootCertStore}; 534 use std::io::Write; 535 use std::net::TcpStream; 536 537 #[test] 538 #[should_panic] 539 fn cert_missing() { 540 let mut server = Server::new(&["--certs", "cert_missing"]); 541 542 // wait for the server to stop, it should crash 543 let _ = server.server.wait(); 544 } 545 546 #[test] 547 #[should_panic] 548 fn key_missing() { 549 let mut server = Server::new(&["--certs", "key_missing"]); 550 551 // wait for the server to stop, it should crash 552 let _ = server.server.wait(); 553 } 554 555 #[test] 556 fn example_com() { 557 let mut server = Server::new(&["--certs", "multicert"]); 558 559 let mut certs = RootCertStore::empty(); 560 certs 561 .add(CertificateDer::from( 562 include_bytes!("data/multicert/example.com/cert.der").as_slice(), 563 )) 564 .unwrap(); 565 let config = rustls::ClientConfig::builder() 566 .with_root_certificates(certs) 567 .with_no_client_auth(); 568 569 let mut session = ClientConnection::new( 570 std::sync::Arc::new(config), 571 "example.com".try_into().unwrap(), 572 ) 573 .unwrap(); 574 let mut tcp = TcpStream::connect(server.get_addr()).unwrap(); 575 let mut tls = rustls::Stream::new(&mut session, &mut tcp); 576 577 write!(tls, "gemini://example.com/\r\n").unwrap(); 578 579 let mut buf = [0; 10]; 580 let _ = tls.read(&mut buf); 581 582 server.stop().unwrap(); 583 } 584 585 #[test] 586 fn example_org() { 587 let mut server = Server::new(&["--certs", "multicert"]); 588 589 let mut certs = RootCertStore::empty(); 590 certs 591 .add(CertificateDer::from( 592 include_bytes!("data/multicert/example.org/cert.der").as_slice(), 593 )) 594 .unwrap(); 595 let config = rustls::ClientConfig::builder() 596 .with_root_certificates(certs) 597 .with_no_client_auth(); 598 599 let mut session = ClientConnection::new( 600 std::sync::Arc::new(config), 601 "example.org".try_into().unwrap(), 602 ) 603 .unwrap(); 604 let mut tcp = TcpStream::connect(server.get_addr()).unwrap(); 605 let mut tls = rustls::Stream::new(&mut session, &mut tcp); 606 607 write!(tls, "gemini://example.org/\r\n").unwrap(); 608 609 let mut buf = [0; 10]; 610 let _ = tls.read(&mut buf); 611 612 server.stop().unwrap(); 613 } 614 } 615 616 mod directory_listing { 617 use super::*; 618 619 #[test] 620 /// - shows directory listing when enabled 621 /// - shows directory listing preamble correctly 622 /// - encodes link URLs correctly 623 fn with_preamble() { 624 let page = get(&["--content", "dirlist-preamble"], "gemini://localhost/") 625 .expect("could not get page"); 626 627 assert_eq!(page.status, Status::Success.value()); 628 assert_eq!(page.meta, "text/gemini"); 629 assert_eq!( 630 page.content, 631 b"This is a directory listing\n=> a\n=> b\n=> wao%20spaces wao spaces\n" 632 ); 633 } 634 635 #[test] 636 fn empty_preamble() { 637 let page = 638 get(&["--content", "dirlist"], "gemini://localhost/").expect("could not get page"); 639 640 assert_eq!(page.status, Status::Success.value()); 641 assert_eq!(page.meta, "text/gemini"); 642 assert_eq!(page.content, b"=> a\n=> b\n"); 643 } 644 }