agate

Simple gemini server for static files
git clone https://github.com/mbrubeck/agate.git
Log | Files | Refs | README

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 }