package net.usebox.gemini.server import java.nio.file.FileSystems import scala.concurrent.duration._ import org.scalatest.flatspec.AnyFlatSpec import org.scalatest.matchers.should.Matchers import akka.util.ByteString class ServerSpec extends AnyFlatSpec with Matchers { def getPath(value: String) = FileSystems.getDefault().getPath(value) behavior of "validPath" it should "return true for the emtpy path" in { Server(TestData.conf).validPath("") shouldBe true } it should "return true for valid paths" in { List("/", "/file", "/./", "/.", "/dir/", "/dir/../").foreach { p => Server(TestData.conf).validPath(p) shouldBe true } } it should "return false for invalid paths" in { List("/../", "/..", "/dir/../..", "/dir/../..", "/./../", "/./dir/.././../") .foreach { p => Server(TestData.conf).validPath(p) shouldBe false } } behavior of "guessMimeType using the internal resolver" it should "resolve a known MIME type" in { Server(TestData.conf) .guessMimeType( getPath("file.html"), None ) shouldBe "text/html" } it should "resolve de default MIME type for unknown types" in { Server(TestData.conf) .guessMimeType( getPath("unknow"), None ) shouldBe TestData.conf.defaultMimeType } it should "resolve gemini MIME type" in { Server(TestData.conf) .guessMimeType( getPath("file.gmi"), None ) shouldBe "text/gemini" Server(TestData.conf) .guessMimeType( getPath("file.gemini"), None ) shouldBe "text/gemini" } it should "resolve gemini MIME type, including parameters" in { Server(TestData.conf) .guessMimeType( getPath("file.gmi"), Some("param") ) shouldBe "text/gemini; param" Server(TestData.conf) .guessMimeType( getPath("file.gemini"), Some("param") ) shouldBe "text/gemini; param" } it should "gemini MIME type parameters are sanitized" in { Server(TestData.conf) .guessMimeType( getPath("file.gmi"), Some(" ; param") ) shouldBe "text/gemini; param" } behavior of "guessMimeType using the configured types" it should "resolve a known MIME type" in { Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes)) .guessMimeType( getPath("file.gmi"), None ) shouldBe "config" } it should "include parameters for text/gemini MIME types" in { Server( TestData.conf.copy(mimeTypes = Some(Map("text/gemini" -> List(".gmi")))) ).guessMimeType( getPath("file.gmi"), Some("param") ) shouldBe "text/gemini; param" } it should "resolve de default MIME type for unknown types" in { Server(TestData.conf.copy(mimeTypes = TestData.mimeTypes)) .guessMimeType( getPath("unknow"), None ) shouldBe TestData.conf.defaultMimeType } behavior of "decodeUTF8" it should "return right on valid UTF-8 codes" in { Server(TestData.conf) .decodeUTF8(ByteString("vĂ¡lid UTF-8")) shouldBe Symbol( "right" ) } it should "return left on invalid UTF-8 codes" in { Server(TestData.conf) .decodeUTF8(ByteString(Array(0xc3.toByte, 0x28.toByte))) shouldBe Symbol( "left" ) } behavior of "handleReq" it should "return bad request on URLs with no scheme" in { Server(TestData.conf) .handleReq("//localhost/", "127.0.0.1") should be(a[BadRequest]) } it should "return proxy request refused on port mismatch" in { Server(TestData.conf) .handleReq("gemini://localhost:8080/", "127.0.0.1") should be( a[ProxyRequestRefused] ) } it should "return proxy request refused when port not provided and configured port is not default" in { Server(TestData.conf.copy(port = 8080)) .handleReq("gemini://localhost/", "127.0.0.1") should be( a[ProxyRequestRefused] ) } it should "return success when port is provided and matches configured port (not default)" in { Server(TestData.conf.copy(port = 8080)) .handleReq("gemini://localhost:8080/", "127.0.0.1") should be(a[Success]) } it should "return proxy request refused when the vhost is not found" in { Server(TestData.conf) .handleReq("gemini://otherhost/", "127.0.0.1") should be( a[ProxyRequestRefused] ) } it should "return bad request when user info is present" in { Server(TestData.conf) .handleReq("gemini://user@localhost/", "127.0.0.1") should be( a[BadRequest] ) } it should "return bad request when the path is out of root dir" in { Server(TestData.conf) .handleReq("gemini://localhost/../../", "127.0.0.1") should be( a[BadRequest] ) } it should "return bad request for invalid URLs" in { Server(TestData.conf) .handleReq( "gemini://localhost/ invalid", "127.0.0.1" ) should be(a[BadRequest]) } it should "redirect to normalize the URL" in { Server(TestData.conf) .handleReq("gemini://localhost/./", "127.0.0.1") should be( a[PermanentRedirect] ) } it should "return not found if the path doesn't exist" in { Server(TestData.conf) .handleReq( "gemini://localhost/doesnotexist", "127.0.0.1" ) should be(a[NotFound]) } it should "return not found if a dot file" in { Server(TestData.conf) .handleReq( "gemini://localhost/.dotfile", "127.0.0.1" ) should be(a[NotFound]) } it should "return success on reading file" in { Server(TestData.conf) .handleReq( "gemini://localhost/index.gmi", "127.0.0.1" ) should matchPattern { case Success(_, "text/gemini", Some(_), 25L) => } } it should "redirect and normalize request on a directory" in { Server(TestData.conf) .handleReq("gemini://localhost/dir", "127.0.0.1") should be( a[PermanentRedirect] ) } it should "return an existing index file when requesting a directory" in { Server(TestData.conf) .handleReq("gemini://localhost/", "127.0.0.1") should matchPattern { case Success(_, "text/gemini", Some(_), 25L) => } } it should "return proxy request refused for non gemini schemes" in { Server(TestData.conf) .handleReq("https://localhost/", "127.0.0.1") should be( a[ProxyRequestRefused] ) } it should "include gemini params for gemini MIME type" in { Server( TestData.conf.copy(virtualHosts = List(TestData.conf.virtualHosts(0).copy(geminiParams = Some("test"))) ) ).handleReq( "gemini://localhost/index.gmi", "127.0.0.1" ) should matchPattern { case Success(_, "text/gemini; test", Some(_), 25L) => } } behavior of "handleReq, directory listings" it should "return a directory listing if is enabled and no index" in { Server(TestData.conf) .handleReq("gemini://localhost/dir/", "127.0.0.1") should be( a[DirListing] ) } it should "return a directory listing, directory listing flags: vhost flag false, directories flag true" in { Server( ServiceConf.initConf( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy( directoryListing = false, directories = List( Directory( "dir/", directoryListing = Some(true), allowCgi = None ) ) ) ) ) ) ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[DirListing]) } it should "return not found with no index, directory listing flags: vhost flag true, directories flag false" in { Server( ServiceConf.initConf( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy( directoryListing = true, directories = List( Directory( "dir/", directoryListing = Some(false), allowCgi = None ) ) ) ) ) ) ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound]) } it should "not apply directory listing override to subdirectories" in { Server( ServiceConf.initConf( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy( directoryListing = false, directories = List( Directory( "dir/", directoryListing = Some(true), allowCgi = None ) ) ) ) ) ) ).handleReq("gemini://localhost/dir/sub/", "127.0.0.1") should be( a[NotFound] ) } it should "return not found if directory listing is not enabled and no index" in { Server( TestData.conf.copy(virtualHosts = List(TestData.conf.virtualHosts(0).copy(directoryListing = false)) ) ).handleReq("gemini://localhost/dir/", "127.0.0.1") should be(a[NotFound]) } behavior of "handleReq, user directories" it should "return success on reading file" in { Server(TestData.confUserDir).handleReq( "gemini://localhost/~username/index.gmi", "127.0.0.1" ) should matchPattern { case Success(_, "text/gemini", Some(_), 38L) => } } it should "return redirect accessing the user directory without ending slash" in { Server(TestData.confUserDir).handleReq( "gemini://localhost/~username", "127.0.0.1" ) should be(a[PermanentRedirect]) } it should "return success accessing the user directory index" in { Server(TestData.confUserDir).handleReq( "gemini://localhost/~username/", "127.0.0.1" ) should matchPattern { case Success(_, "text/gemini", Some(_), 38L) => } } it should "return bad request trying to exit the root directory" in { Server(TestData.confUserDir).handleReq( "gemini://localhost/~username/../../", "127.0.0.1" ) should be(a[BadRequest]) } it should "return redirect to the virtual host root when leaving the user dir" in { Server(TestData.confUserDir).handleReq( "gemini://localhost/~username/../", "127.0.0.1" ) should be(a[PermanentRedirect]) } it should "not translate root if used an invalid user pattern" in { Server(TestData.confUserDir).handleReq( "gemini://localhost/~username../", "127.0.0.1" ) should be(a[NotFound]) Server(TestData.confUserDir).handleReq( "gemini://localhost/~0invalid/", "127.0.0.1" ) should be(a[NotFound]) } it should "not translate root if no user directory path was provided" in { Server( TestData.conf.copy(virtualHosts = List( TestData.conf .virtualHosts(0) .copy(userDirectories = true) ) ) ).handleReq( "gemini://localhost/~username/", "127.0.0.1" ) should be(a[NotFound]) } it should "not execute a CGI if the target resource is not executable" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/file.txt", "127.0.0.1" ) should matchPattern { case Success(_, "text/plain", Some(_), 5L) => } } it should "not execute a CGI if the target resource is a directory" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/sub/", "127.0.0.1" ) should be(a[DirListing]) } it should "not apply allow CGI to subdirectories" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/sub/cgi", "127.0.0.1" ) should matchPattern { case Success(_, "text/plain", Some(_), 72) => } } it should "execute a CGI" in { val cgi = Server(TestData.cgiConf) .handleReq( "gemini://localhost/dir/cgi", "127.0.0.1" ) .asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("GATEWAY_INTERFACE=CGI/1.1") } it should "execute a CGI: empty parameters, host and port" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/cgi", "127.0.0.1" ) should matchPattern { case Cgi( _, _, "", "", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: query string" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/cgi?query&string", "127.0.0.1" ) should matchPattern { case Cgi( _, _, "query&string", "", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: path info" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/cgi/path/info", "127.0.0.1" ) should matchPattern { case Cgi( _, _, "", "/path/info", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "execute a CGI: query string and path info" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/cgi/path/info?query=string", "127.0.0.1" ) should matchPattern { case Cgi( _, _, "query=string", "/path/info", "cgi", TestData.host, TestData.portStr, _, _ ) => } } it should "not execute an executable if allow CGI is off" in { Server(TestData.conf) .handleReq( "gemini://localhost/dir/cgi", "127.0.0.1" ) should matchPattern { case Success(_, "text/plain", Some(_), _) => } } it should "response with an error if the CGI exits with non 0" in { val bad = Server(TestData.cgiConf) .handleReq( "gemini://localhost/dir/bad-cgi", "127.0.0.1" ) .asInstanceOf[Cgi] val meta = "Error executing CGI" bad.status should be(50) bad.meta should be(meta) bad.body should include(meta) } it should "return a response with an error if the CGI exits with non 0" in { val bad = Server(TestData.cgiConf) .handleReq( "gemini://localhost/dir/bad-cgi", "127.0.0.1" ) .asInstanceOf[Cgi] val meta = "Error executing CGI" bad.status should be(50) bad.meta should be(meta) bad.body should include(meta) } it should "return a response with an error if the CGI response is invalid" in { val bad = Server(TestData.cgiConf) .handleReq( "gemini://localhost/dir/bad-response", "127.0.0.1" ) .asInstanceOf[Cgi] val meta = "Invalid response from CGI" bad.status should be(40) bad.meta should be(meta) bad.body should include(meta) } it should "environment variables are optional" in { Server(TestData.cgiConf).handleReq( "gemini://localhost/dir/cgi/", "127.0.0.1" ) should matchPattern { case Cgi( _, _, _, _, "cgi", TestData.host, TestData.portStr, _, m ) if m == Map() => } } it should "pass environment variables to the CGI" in { Server(TestData.cgiEnvConf).handleReq( "gemini://localhost/dir/cgi/", "127.0.0.1" ) should matchPattern { case Cgi( _, _, _, _, "cgi", TestData.host, TestData.portStr, _, m ) if m == Map("env1" -> "value") => } } it should "execute a CGI with the environment variables" in { val cgi = Server(TestData.cgiEnvConf) .handleReq( "gemini://localhost/dir/cgi", "127.0.0.1" ) .asInstanceOf[Cgi] cgi.status should be(20) cgi.meta should be("text/gemini") cgi.body should include("env1=value") } object TestData { val host = "localhost" val port = 1965 val portStr = port.toString() val conf = ServiceConf( address = "127.0.0.1", port = port, defaultMimeType = "text/plain", idleTimeout = 10.seconds, virtualHosts = List( VirtualHost( host = host, root = getClass.getResource("/").getPath() ) ), genCertValidFor = 1.day, enabledProtocols = Nil, enabledCipherSuites = Nil ) val cgiConf = ServiceConf.initConf( conf.copy(virtualHosts = List( conf .virtualHosts(0) .copy( directoryListing = true, directories = List( Directory( "dir/", directoryListing = Some(false), allowCgi = Some(true) ) ) ) ) ) ) val cgiEnvConf = cgiConf.copy(virtualHosts = List( cgiConf .virtualHosts(0) .copy( environment = Some(Map("env1" -> "value")) ) ) ) val confUserDir = conf.copy(virtualHosts = List( conf .virtualHosts(0) .copy( userDirectories = true, userDirectoryPath = Some( getClass.getResource("/").getPath() + "{user}/public_gemini/" ) ) ) ) val mimeTypes = Some( Map( "config" -> List(".gmi", ".gemini") ) ) } }