diff --git a/src/Orbit/Cert.php b/src/Orbit/Cert.php
index 68ae6b7..af86fd7 100644
--- a/src/Orbit/Cert.php
+++ b/src/Orbit/Cert.php
@@ -1,9 +1,14 @@
-hostname = $config->hostname;
$this->tls_certfile = $config->tls_certfile;
$this->tls_keyfile = $config->tls_keyfile;
$this->key_passphrase = $config->key_passphrase;
- $this->logger = $logger;
+ if ($logger !== null) {
+ $this->logger = $logger;
+ } else {
+ $this->logger = new Logger('orbit-cert');
+ }
if ($config->getIsDevelopmentServer()) {
$this->initDevelopment();
@@ -34,7 +50,7 @@ class Cert
*
* @return bool
*/
- public function initDevelopment()
+ public function initDevelopment(): bool
{
$this->logger->debug("Initialize cert for development mode.");
@@ -71,7 +87,7 @@ class Cert
*
* @return bool
*/
- public function initProduction()
+ public function initProduction(): bool
{
$this->logger->debug("Initialize cert for production mode.");
@@ -99,10 +115,16 @@ class Cert
$this->logger->debug(sprintf("Using cert file '%s'", $this->tls_certfile));
$this->logger->debug(sprintf("Using key file '%s'", $this->tls_keyfile));
+
return true;
}
- private function generateCert()
+ /**
+ * Generate a self-signed cert
+ *
+ * @return void
+ */
+ private function generateCert(): void
{
// Certificate data
$dn = [
@@ -157,7 +179,7 @@ class Cert
* @param array $san_domains
* @return string Filename
*/
- private function createOpenSslConf($san_domains = [])
+ private function createOpenSslConf(array $san_domains = []): string
{
$san_domains_string = implode(",", $san_domains);
@@ -187,7 +209,15 @@ EOS;
return $temp_filename;
}
- private function ensureDirExists($filename)
+ /**
+ * Ensure directory for a given filename exists
+ *
+ * Will recursively create parent directories if they don't exist
+ *
+ * @param string $filename
+ * @return void
+ */
+ private function ensureDirExists(string $filename): void
{
$dir = dirname($filename);
diff --git a/src/Orbit/Config.php b/src/Orbit/Config.php
index 2ce3122..8f1a4c6 100644
--- a/src/Orbit/Config.php
+++ b/src/Orbit/Config.php
@@ -1,7 +1,12 @@
-setIsDevelopmentServer($is_development);
+ $this->setIsDevelopmentServer($is_development_server);
}
- public function setIsDevelopmentServer($is_development_server)
+ public function setIsDevelopmentServer($is_development_server): void
{
$this->is_development_server = (bool) $is_development_server;
- return $this;
}
- public function getIsDevelopmentServer()
+ public function getIsDevelopmentServer(): bool
{
return $this->is_development_server;
}
- public function readFromIniFile($filename)
+ /**
+ * Read config values from ini file
+ *
+ * @param string $filename Path to ini file
+ * @return void
+ */
+ public function readFromIniFile(string $filename)
{
if (!file_exists($filename) || !is_readable($filename)) {
throw new \Exception("Cannot read config file '$filename'");
diff --git a/src/Orbit/Console.php b/src/Orbit/Console.php
index 38096b9..1bf677a 100644
--- a/src/Orbit/Console.php
+++ b/src/Orbit/Console.php
@@ -1,4 +1,4 @@
-_args->get('no-color')) {
$this->_terminal->setIsatty(false);
@@ -25,7 +38,9 @@ class Console extends \Qi_Console_Client
}
$config = $this->makeConfig();
- $logger = $this->makeLogger($config, $this->_args->quiet);
+ if (!$logger) {
+ $logger = $this->makeLogger($config, $this->_args->quiet);
+ }
if (!$this->_args->quiet) {
print "Orbit // Gemini server software\n";
@@ -34,10 +49,20 @@ class Console extends \Qi_Console_Client
$cert = new Cert($config, $logger);
$server = new Server($config, $cert, $logger);
- $server->listen();
+
+ if (!self::$under_test) {
+ $server->listen();
+ }
+
+ return 0;
}
- public function makeConfig()
+ /**
+ * Make Config object
+ *
+ * @return Config
+ */
+ public function makeConfig(): Config
{
$config = new Config($this->_args->dev);
@@ -80,7 +105,14 @@ class Console extends \Qi_Console_Client
return $config;
}
- public function makeLogger($config, $is_quiet = false)
+ /**
+ * Make Logger object
+ *
+ * @param Config $config
+ * @param bool $is_quiet
+ * @return Logger
+ */
+ public function makeLogger(Config $config, $is_quiet = false): Logger
{
$logger = new Logger('orbit');
@@ -102,7 +134,12 @@ class Console extends \Qi_Console_Client
return $logger;
}
- public function makeLogFormatter($is_tty = false)
+ /**
+ * Make a LineFormatter object
+ *
+ * @return LineFormatter
+ */
+ private function makeLogFormatter($is_tty = false): LineFormatter
{
$pid = getmypid();
@@ -118,12 +155,22 @@ class Console extends \Qi_Console_Client
return new LineFormatter($output, 'Y-m-d\TH:i:s');
}
- public function showVersion()
+ /**
+ * Print the version of orbit server
+ *
+ * @return void
+ */
+ public function showVersion(): void
{
print "Orbit " . Server::$version . "\n";
}
- public function showHelp()
+ /**
+ * Print the help message
+ *
+ * @return void
+ */
+ public function showHelp(): void
{
$this->showVersion();
diff --git a/src/Orbit/Module.php b/src/Orbit/Module.php
new file mode 100644
index 0000000..46e49fb
--- /dev/null
+++ b/src/Orbit/Module.php
@@ -0,0 +1,31 @@
+response handler can do
+ *
+ * @package Orbit
+ */
+class Module
+{
+ protected $config;
+ protected $logger;
+
+ /**
+ * Constructor
+ *
+ * @param Config $config
+ * @param Logger $logger
+ * @return void
+ */
+ public function __construct(Config $config, Logger $logger)
+ {
+ $this->config = $config;
+ $this->logger = $logger;
+ }
+}
diff --git a/src/Orbit/Module/Statics.php b/src/Orbit/Module/Statics.php
new file mode 100644
index 0000000..fbb75fc
--- /dev/null
+++ b/src/Orbit/Module/Statics.php
@@ -0,0 +1,152 @@
+path;
+
+ // Check if within the server root
+ // Realpath will translate any '..' in the path
+ $realpath = realpath($resource_path);
+ if ($realpath && strpos($realpath, $real_root_dir) !== 0) {
+ $response->setStatus(Response::STATUS_PERMANENT_FAILURE);
+ $response->setMeta("Invalid location");
+ return [true, $response];
+ }
+
+ if (is_dir($resource_path)) {
+ // If missing the final slash, issue a redirect
+ if ($resource_path[-1] != "/") {
+ $response->setStatus(Response::STATUS_REDIRECT_PERMANENT);
+ $response->setMeta($request->getUrlAppendPath('/'));
+ return [true, $response];
+ }
+
+ // Check if index file exists
+ if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) {
+ $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file;
+ } else {
+ if (!$this->config->enable_directory_index) {
+ $response->setStatus(Response::STATUS_BAD_REQUEST);
+ $response->setMeta('Path not available');
+ return [true, $response];
+ } else {
+ $response->setStatus(Response::STATUS_SUCCESS);
+ $response->setMeta('text/gemini');
+ $response->setBody($this->makeDirectoryIndex($resource_path, $real_root_dir));
+ return [true, $response];
+ }
+ }
+ }
+
+ // File exists and is world readable
+ if (file_exists($resource_path) && self::isWorldReadble($resource_path)) {
+ $response->setStatus(Response::STATUS_SUCCESS);
+
+ $pathinfo = pathinfo($resource_path);
+
+ // TODO : handle files without extensions
+ if (!isset($pathinfo['extension'])) {
+ $response->setStatus(Response::STATUS_TEMPORARY_FAILURE);
+ $response->setMeta('Error reading resource');
+ return [true, $response];
+ }
+
+ $meta = $this->getCustomMimeFromFileExtension($pathinfo['extension']);
+ if (!$meta) {
+ $meta = mime_content_type($resource_path);
+ }
+ $response->setMeta($meta);
+ $response->setStaticFile($resource_path);
+ } else {
+ $response->setStatus(Response::STATUS_NOT_FOUND);
+ $response->setMeta('Not found!');
+ }
+
+ return [true, $response];
+ }
+
+ /**
+ * Get mime type from file extension for custom types
+ *
+ * @param string $extension
+ * @return string
+ */
+ public function getCustomMimeFromFileExtension($extension): string
+ {
+ switch ($extension) {
+ case 'gmi':
+ case 'gemini':
+ return 'text/gemini';
+ break;
+ case 'md':
+ case 'markdown':
+ return 'text/gemini';
+ break;
+ case 'ans':
+ case 'ansi':
+ return 'text/x-ansi';
+ break;
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Make a directory index suitable as response content
+ *
+ * @param string $path Current path
+ * @param string $root Root path on disk of the server
+ * @return string
+ */
+ public function makeDirectoryIndex($path, $root): string
+ {
+ $files = glob($path . "*");
+
+ $body = "# Directory listing " . str_replace($root, '', $path) . "\n\n";
+ $body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n";
+
+ foreach ($files as $file) {
+ $relative_path = str_replace($path, '', $file);
+
+ $is_dir = false;
+ if (is_dir($file)) {
+ $is_dir = true;
+ $size = '';
+ } else {
+ $size = filesize($file);
+ }
+
+ $body .= sprintf(
+ "=> %s%s %s%s%s\n",
+ urlencode($relative_path),
+ ($is_dir ? '/' : ''),
+ $relative_path,
+ ($is_dir ? '/' : ''),
+ ($size ? " ($size)" : '')
+ );
+ }
+
+ return $body;
+ }
+}
diff --git a/src/Orbit/Request.php b/src/Orbit/Request.php
index acf86b5..1670fb0 100644
--- a/src/Orbit/Request.php
+++ b/src/Orbit/Request.php
@@ -1,7 +1,12 @@
-url = trim($request_input);
$data = parse_url($this->url);
foreach ($data as $key => $value) {
- $this->{$key} = urldecode($value);
+ $this->{$key} = urldecode((string) $value);
}
// If scheme is missing, infer as default scheme
@@ -29,7 +40,12 @@ class Request
}
}
- public function getUrlAppendPath($text)
+ /**
+ * Get a new URL with some text appended to the path
+ *
+ * @return string New URL
+ */
+ public function getUrlAppendPath(string $text): string
{
return $this->scheme . '://'
. ($this->user ? $this->user : '')
diff --git a/src/Orbit/Response.php b/src/Orbit/Response.php
index 3814db2..0415f32 100644
--- a/src/Orbit/Response.php
+++ b/src/Orbit/Response.php
@@ -1,7 +1,15 @@
-status = $status;
$this->meta = $meta;
}
- public function getHeader()
+ /**
+ * Get a header to be sent to client
+ *
+ * @return string
+ */
+ public function getHeader(): string
{
return sprintf("%s %s\r\n", $this->status, $this->meta);
}
- public function send($client)
+ /**
+ * Send data to client
+ *
+ * @param resource $client
+ * @return int|false Number of bytes written
+ */
+ public function send($client): int
{
+ if (!is_resource($client)) {
+ throw new \Exception("Invalid resource to write to");
+ }
$result = fwrite($client, $this->getHeader());
if (!$result) {
- return false;
+ throw new \Exception("Failed to write to client");
}
if ($this->filepath) {
- $size = filesize($this->filepath);
- $fp = fopen($this->filepath, "rb");
- if (false === $fp) {
+ if (is_dir($this->filepath)) {
+ throw new \Exception("Cannot serve directory '{$this->filepath}'");
+ }
+
+ if (!file_exists($this->filepath)) {
throw new \Exception("Error reading file '{$this->filepath}'");
}
+ $fp = fopen($this->filepath, "rb");
+
+ if (false === $fp) {
+ throw new \Exception("Cannot read file '{$this->filepath}'");
+ }
+
+ $size = filesize($this->filepath);
+
$result = 1;
while (!feof($fp) && $result) {
// If the client cancels, bail out before trying large files
@@ -69,35 +108,63 @@ class Response
}
}
- public function setBody($body = '')
+ /**
+ * Set response body
+ *
+ * @param string $body
+ * @return void
+ */
+ public function setBody(string $body = ''): void
{
$this->body = $body;
- return $this;
}
- public function getBody()
+ /**
+ * Get response body
+ *
+ * @return string
+ */
+ public function getBody(): string
{
if ($this->filepath) {
$this->body = file_get_contents($this->filepath);
}
+
return $this->body;
}
- public function setStaticFile($filepath)
+ /**
+ * Set static file
+ *
+ * Indicates this response should consist of a static file on disk
+ *
+ * @param mixed $filepath
+ * @return void
+ */
+ public function setStaticFile(string $filepath): void
{
$this->filepath = $filepath;
- return $this;
}
- public function setStatus($status)
+ /**
+ * Set response status
+ *
+ * @param int $status
+ * @return void
+ */
+ public function setStatus($status): void
{
$this->status = $status;
- return $this;
}
- public function setMeta($meta)
+ /**
+ * Set response meta value
+ *
+ * @param string $meta
+ * @return void
+ */
+ public function setMeta($meta): void
{
$this->meta = $meta;
- return $this;
}
}
diff --git a/src/Orbit/Server.php b/src/Orbit/Server.php
index 78d0da1..869782d 100644
--- a/src/Orbit/Server.php
+++ b/src/Orbit/Server.php
@@ -1,4 +1,4 @@
-config = new Config();
- } else {
- $this->config = $config;
- }
+ $this->config = $config;
if ($logger !== null) {
$this->setLogger($logger);
} else {
- $this->getLogger();
- }
-
- if ($cert == null) {
- $this->cert = new Cert($config, $this->getLogger());
- } else {
- $this->cert = $cert;
- }
-
- $this->timeout = ini_get("default_socket_timeout");
-
- $this->ssl_context = $this->createSslContext();
- }
-
- public function setLogger(Logger $logger)
- {
- $this->logger = $logger;
- }
-
- public function getLogger()
- {
- if (!$this->logger) {
$this->logger = new Logger('orbit');
$this->logger->pushHandler(new StreamHandler($this->config->log_file, Logger::INFO));
}
+ if ($cert == null) {
+ $this->cert = new Cert($this->config, $this->getLogger());
+ } else {
+ $this->cert = $cert;
+ }
+
+ $this->timeout = (int) ini_get("default_socket_timeout");
+
+ $this->ssl_context = $this->createSslContext();
+ }
+
+ /**
+ * Set logger object
+ *
+ * @param Logger $logger
+ */
+ public function setLogger(Logger $logger): void
+ {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Get logger
+ *
+ * @return Logger
+ */
+ public function getLogger(): Logger
+ {
return $this->logger;
}
- public function listen($root_dir = null)
+ /**
+ * Listen and handle incoming requests
+ *
+ * @param mixed $root_dir The root directory from which to serve files
+ * @return void
+ */
+ public function listen($root_dir = null): void
{
if (null == $root_dir) {
$root_dir = $this->config->root_dir;
@@ -78,7 +102,8 @@ class Server
$server = stream_socket_server(
$this->getListenAddress(),
- $errno, $errstr,
+ $errno,
+ $errstr,
STREAM_SERVER_BIND|STREAM_SERVER_LISTEN,
$this->ssl_context
);
@@ -115,16 +140,28 @@ class Server
}
}
- public function intakeConnections($server)
+ /**
+ * Intake connections
+ *
+ * @param resource $server
+ * @return bool
+ */
+ public function intakeConnections($server): bool
{
+ if (!is_resource($server)) {
+ throw new \Exception("Stream server must be resource object");
+ }
+
set_error_handler([$this, 'onWarning']);
# onWarning is added here to swallow up the `timeout` warning
$client = stream_socket_accept($server, $this->timeout, $peer);
restore_error_handler();
if (!$client) {
+ # If the listener times out, it will run this and then start
+ # listening again
$this->pruneExpiredConnections();
- return;
+ return false;
}
$time = ['start' => microtime(true)];
@@ -133,9 +170,17 @@ class Server
$this->logger->debug("$peer Accepted", $meta);
$this->connections[$peer] = $client;
$this->metas[$peer] = ["input" => "", "time" => $time];
+
+ return true;
}
- public function handlePeerConnection($client)
+ /**
+ * Handle a single peer connection (client request)
+ *
+ * @param resource $client
+ * @return void
+ */
+ public function handlePeerConnection($client): void
{
$peer = stream_socket_get_name($client, true);
@@ -149,7 +194,7 @@ class Server
|| strpos($this->metas[$peer]['input'], "\r\n") !== false
) {
$request_buffer = $this->metas[$peer]['input'];
- //print($this->hexView($request_buffer));
+ //print(Util::hexView($request_buffer));
//print("Length: " . mb_strlen($request_buffer) . "\n");
$this->logger->info("REQ: $request_buffer", ['client' => $peer]);
@@ -157,15 +202,20 @@ class Server
// Respond to client
$response = $this->handleResponse($request);
- $size = $response->send($client);
- if ($size !== false) {
- $time_end = microtime(true);
- $this->logger->debug(
- "RSP: " . trim($response->getHeader()),
- ['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']]
- );
+ $size = 0;
+
+ try {
+ $size = $response->send($client);
+ } catch (\Exception $e) {
+ $this->logger->error($e->getMessage());
}
+ $time_end = microtime(true);
+ $this->logger->debug(
+ "RSP: " . trim($response->getHeader()),
+ ['size' => $size, 'time' => $time_end - $this->metas[$peer]['time']['start']]
+ );
+
fclose($client);
$this->logger->debug("$peer Closed");
unset($this->connections[$peer]);
@@ -173,79 +223,47 @@ class Server
}
}
- public function handleResponse($request)
+ /**
+ * Handle creating a response and sending it to a client
+ *
+ * @param Request $request
+ * @return Response
+ */
+ public function handleResponse(Request $request): Response
{
- list($is_valid, $response) = $this->validateRequest($request);
-
+ [$is_valid, $response] = $this->validateRequest($request);
if ($is_valid === false) {
+ // If not valid, just return the error response object created by
+ // the validator
return $response;
}
- $resource_path = rtrim($this->real_root_dir, "/") . $request->path;
-
- // Check if within the server root
- // Realpath will translate any '..' in the path
- $realpath = realpath($resource_path);
- if ($realpath && strpos($realpath, $this->real_root_dir) !== 0) {
- $response->setStatus(Response::STATUS_PERMANENT_FAILURE);
- $response->setMeta("Invalid location");
- return $response;
- }
-
- if (is_dir($resource_path)) {
- // If missing the final slash, issue a redirect
- if ($resource_path[-1] != "/") {
- $response->setStatus(Response::STATUS_REDIRECT_PERMANENT);
- $response->setMeta($request->getUrlAppendPath('/'));
- return $response;
- }
-
- // Check if index file exists
- if (file_exists($resource_path . DIRECTORY_SEPARATOR . $this->config->index_file)) {
- $resource_path = $resource_path . DIRECTORY_SEPARATOR . $this->config->index_file;
- } else {
- if (!$this->config->enable_directory_index) {
- $response->setStatus(Response::STATUS_BAD_REQUEST);
- $response->setMeta('Path not available');
- return $response;
- } else {
- $response->setStatus(Response::STATUS_SUCCESS);
- $response->setMeta('text/gemini');
- $response->setBody($this->makeDirectoryIndex($resource_path, $dir));
- return $response;
- }
- }
- }
-
- // File exists and is world readable
- if (file_exists($resource_path) && (fileperms($resource_path) & 0x0004)) {
- $response->setStatus(Response::STATUS_SUCCESS);
-
- $pathinfo = pathinfo($resource_path);
- if (!isset($pathinfo['extension'])) {
- $response->setStatus(Response::STATUS_TEMPORARY_FAILURE);
- $response->setMeta('Error reading resource');
- return $response;
- }
-
- if ($pathinfo['extension'] == 'gmi' || $pathinfo['extension'] == 'gemini') {
- $response->setMeta('text/gemini');
- } elseif ($pathinfo['extension'] == 'md' || $pathinfo['extension'] == 'markdown') {
- $response->setMeta('text/gemini');
- } else {
- $response->setMeta(mime_content_type($resource_path));
- }
-
- $response->setStaticFile($resource_path);
- } else {
- $response->setStatus(Response::STATUS_NOT_FOUND);
- $response->setMeta('Not found!');
- }
+ $statics_module = new Statics($this->config, $this->logger);
+ [$success, $response] = $statics_module->handleResponse($request, $response, $this->real_root_dir);
return $response;
}
- public function pruneExpiredConnections()
+ /**
+ * Report whether a given file is world readable or not
+ *
+ * @param string $file The file to check
+ * @return bool
+ */
+ public static function isWorldReadble(string $file): bool
+ {
+ return (bool)(fileperms($file) & self::WORLD_READABLE);
+ }
+
+ /**
+ * Prune any lingering connections
+ *
+ * Remove any connections that are still 'open' but have been open past a
+ * certain threshold timeout
+ *
+ * @return void
+ */
+ public function pruneExpiredConnections(): void
{
$now = microtime(true);
foreach ($this->metas as $peer => $meta) {
@@ -260,7 +278,16 @@ class Server
}
}
- public function validateRequest($request)
+ /**
+ * Validate a request
+ *
+ * Will return an tuple (array with two values). The first value is whether
+ * it passed the validation logic. The second value is the Response object.
+ *
+ * @param Request $request
+ * @return array [bool, Response]
+ */
+ public function validateRequest(Request $request): array
{
$response = new Response();
@@ -279,7 +306,9 @@ class Server
}
// Valid URL must use targeted hostname
- if ($request->host != "127.0.0.1" && $request->host != "localhost" && $request->host != $this->config->hostname) {
+ if ($request->host != "127.0.0.1" && $request->host != "localhost"
+ && $request->host != $this->config->hostname
+ ) {
$response->setStatus(Response::STATUS_PROXY_REQUEST_REFUSED);
$response->setMeta("Proxy error - invalid host");
return [false, $response];
@@ -310,59 +339,44 @@ class Server
return [true, $response];
}
- public function log($level, $message, $context = [])
+ /**
+ * Special error handler
+ *
+ * For warnings that occur when attempting to start server stream
+ *
+ * @param int $errno
+ * @param string $errstr
+ * @return bool
+ */
+ public function onWarning($errno, $errstr): bool
{
- $this->getLogger()->log($level, $message, $context);
- }
-
- public function onWarning($id, $message)
- {
- if (strpos($message, "timed out") !== false) {
+ if (strpos($errstr, "timed out") !== false) {
// Do nothing
- return;
+ return true;
}
// Something else happened.
- $error = sprintf("Error while accepting connection %s: %s", $id, $message);
- $this->logger->error($error);
+ $message = sprintf("Error while accepting connection %s: %s", $errno, $errstr);
+ $this->logger->error($message);
+
+ return true;
}
- public function getListenAddress()
+ /**
+ * Get the listen address for the stream_server
+ *
+ * @return string
+ */
+ public function getListenAddress(): string
{
return sprintf('tls://%s:%s', $this->config->host, $this->config->port);
}
- public function makeDirectoryIndex($path, $root)
- {
- $files = glob($path . "*");
-
- $body = "# Directory listing " . str_replace($root, '', $path) . "\n\n";
- $body .= "=> " . str_replace($root, '', dirname($path)) . " ..\n";
-
- foreach ($files as $file) {
- $relative_path = str_replace($path, '', $file);
-
- $is_dir = false;
- if (is_dir($file)) {
- $is_dir = true;
- $size = '';
- } else {
- $size = filesize($file);
- }
-
- $body .= sprintf(
- "=> %s%s %s%s%s\n",
- urlencode($relative_path),
- ($is_dir ? '/' : ''),
- $relative_path,
- ($is_dir ? '/' : ''),
- ($size ? " ($size)" : '')
- );
- }
-
- return $body;
- }
-
+ /**
+ * Create SSL context for stream server
+ *
+ * @return resource
+ */
public function createSslContext()
{
$context = stream_context_create();
@@ -377,41 +391,4 @@ class Server
return $context;
}
-
- /**
- * View hex chars of string
- *
- * Outputs a listing of hexidecimal values in 16 byte rows
- *
- * @param mixed $text Input text
- * @return string
- */
- private function hexView($text)
- {
- $num = 16;
- $outStr = '';
- $printableChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
- . 'abcdefghijklmnopqrstuvwxyz'
- . '0123456789~!@#$%^&*()_+-={}|[]\:";\'<>?,./';
-
- $charCount = strlen($text);
- for ($i = 0; $i < $charCount; $i += $num) {
- $printStr = '';
- for ($j = 0; $j < $num; $j++) {
- $char = substr($text, $i+$j, 1);
-
- $outStr .= sprintf("%02X", ord($char)) . " ";
-
- if (ord($char) >= 32 && ord($char) < 127) {
- $printStr .= $char;
- } else {
- $printStr .= ".";
- }
- }
-
- $outStr .= " | " . $printStr . "\n";
- }
-
- return $outStr;
- }
}
diff --git a/src/Orbit/Util.php b/src/Orbit/Util.php
new file mode 100644
index 0000000..274a6eb
--- /dev/null
+++ b/src/Orbit/Util.php
@@ -0,0 +1,41 @@
+= 32 && ord($char) < 127) {
+ $printStr .= $char;
+ } else {
+ $printStr .= ".";
+ }
+ }
+
+ $outStr .= " | " . $printStr . "\n";
+ }
+
+ return $outStr;
+ }
+}
diff --git a/tests/phpunit.xml b/tests/phpunit.xml
new file mode 100644
index 0000000..e2ee11b
--- /dev/null
+++ b/tests/phpunit.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ ../src
+
+
+
+
+ .
+
+
+
diff --git a/tests/src/Orbit/CertTest.php b/tests/src/Orbit/CertTest.php
new file mode 100644
index 0000000..dca7712
--- /dev/null
+++ b/tests/src/Orbit/CertTest.php
@@ -0,0 +1,188 @@
+pushHandler(new \Monolog\Handler\TestHandler());
+
+ return $logger;
+ }
+
+ public function getTestLogRecords($logger): array
+ {
+ return $logger->getHandlers()[0]->getRecords();
+ }
+
+ public function getTestLogMessages($logger): array
+ {
+ $messages = [];
+ foreach ($this->getTestLogRecords($logger) as $message) {
+ $messages[] = $message['level_name'] . ": " . $message['message'];
+ }
+
+ return $messages;
+ }
+
+ public function testConstructDev(): void
+ {
+ $config = new Config(true);
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertEquals(3, count($messages));
+ $this->assertStringContainsString('development mode', $messages[0]);
+ $this->assertStringContainsString('Generating new cert file', $messages[1]);
+ $this->assertStringContainsString('Generating new key file', $messages[2]);
+ }
+
+ public function testConstructDevExistingCertFile(): void
+ {
+ $config = new Config(true);
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('development mode', $messages[0]);
+ $this->assertStringContainsString('Generating new cert file', $messages[1]);
+ $this->assertStringContainsString('Generating new key file', $messages[2]);
+
+ $logger = $this->makeTestLogger();
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('Using existing cert file', $messages[1]);
+ $this->assertStringContainsString('Using existing key file', $messages[2]);
+ }
+
+ public function testConstructDevOverwriteCertWarning(): void
+ {
+ $config = new Config(true);
+ $logger = $this->makeTestLogger();
+
+ @mkdir('certs');
+ file_put_contents('certs/localhost.cert.pem', '####');
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('May overwrite existing cert file', $messages[3]);
+ }
+
+ public function testConstructDevSetCertFile(): void
+ {
+ $config = new Config(true);
+ $config->tls_certfile = 'certs/subd/wahoo.cert.pem';
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('certs/subd/wahoo.cert.pem', $messages[1]);
+ @unlink("certs/subd/wahoo.cert.pem");
+ }
+
+ public function testConstructDevSetKeyFile(): void
+ {
+ $config = new Config(true);
+ $config->tls_keyfile = 'certs/wahoo.key.pem';
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('certs/wahoo.key.pem', $messages[2]);
+ @unlink("certs/wahoo.key.pem");
+ }
+
+ public function testConstructDevFilesUsingHostname(): void
+ {
+ $config = new Config(true);
+ $config->hostname = 'particle';
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('certs/particle.cert.pem', $messages[1]);
+ $this->assertStringContainsString('certs/particle.key.pem', $messages[2]);
+ @unlink("certs/particle.cert.pem");
+ @unlink("certs/particle.key.pem");
+ }
+
+ public function testConstructDevOverwriteKeyWarning(): void
+ {
+ $config = new Config(true);
+ $logger = $this->makeTestLogger();
+
+ @mkdir('certs');
+ file_put_contents('certs/localhost.key.pem', '####');
+ $cert = new Cert($config, $logger);
+ $this->assertInstanceOf(Cert::class, $cert);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('May overwrite existing key file', $messages[3]);
+ }
+
+ public function testConstructProd(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Missing required cert file");
+
+ $config = new Config();
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ }
+
+ public function testConstructProdDefinedButMissingFiles(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("does not exist or is not readable");
+
+ $config = new Config();
+ $config->tls_certfile = 'localhost.cert.pem';
+ $config->tls_keyfile = 'localhost.key.pem';
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+ }
+
+ public function testConstructProdDefinedFiles(): void
+ {
+ // first run in development mode to auto-generate certs
+ $config = new Config(true);
+ $logger = $this->makeTestLogger();
+ $cert = new Cert($config, $logger);
+
+ // now run in prod mode to use those cert files
+ $config = new Config();
+ $config->tls_certfile = 'certs/localhost.cert.pem';
+ $config->tls_keyfile = 'certs/localhost.key.pem';
+ $logger = $this->makeTestLogger();
+
+ $cert = new Cert($config, $logger);
+
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('Using cert file', $messages[1]);
+ $this->assertStringContainsString('Using key file', $messages[2]);
+ }
+
+ public function tearDown(): void
+ {
+ @unlink("certs/localhost.cert.pem");
+ @unlink("certs/localhost.key.pem");
+ @rmdir("certs/subd");
+ }
+}
diff --git a/tests/src/Orbit/ConfigTest.php b/tests/src/Orbit/ConfigTest.php
new file mode 100644
index 0000000..4492c07
--- /dev/null
+++ b/tests/src/Orbit/ConfigTest.php
@@ -0,0 +1,99 @@
+assertInstanceOf(Config::class, $config);
+ }
+
+ public function testConstructDev(): void
+ {
+ $config = new Config(true);
+ $this->assertInstanceOf(Config::class, $config);
+ $this->assertTrue($config->getIsDevelopmentServer());
+ }
+
+ public function testConstructDevFromTruthyValue(): void
+ {
+ $config = new Config("yes");
+ $this->assertInstanceOf(Config::class, $config);
+ $this->assertTrue($config->getIsDevelopmentServer());
+ }
+
+ public function testReadFromIniFile(): void
+ {
+ $data = "hostname=fabricate.dev";
+ file_put_contents('test.ini', $data);
+
+ $config = new Config();
+ $config->readFromIniFile('test.ini');
+ $this->assertSame('fabricate.dev', $config->hostname);
+
+ @unlink('test.ini');
+ }
+
+ public function testReadFromIniNonFile(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage('Cannot read config file');
+
+ $config = new Config();
+ $config->readFromIniFile('nonfile.ini');
+ }
+
+ public function testReadFromIniAllPossibleKeys(): void
+ {
+ $data = <<readFromIniFile('test.ini');
+ $this->assertSame('1.1.1.1', $config->host);
+ $this->assertSame('1988', $config->port);
+ $this->assertSame('beatles.org', $config->hostname);
+ $this->assertSame('1212', $config->tls_certfile);
+ $this->assertSame('3434', $config->tls_keyfile);
+ $this->assertSame('strawberry', $config->keypassphrase);
+ $this->assertSame('xyz.log', $config->log_file);
+ $this->assertSame('cherry', $config->log_level);
+ $this->assertSame('blueberry', $config->root_dir);
+ $this->assertSame('led.gmi', $config->index_file);
+ $this->assertSame('0', $config->enable_directory_index);
+
+ @unlink('test.ini');
+ }
+
+ public function testReadFromIniWithInvalidKey(): void
+ {
+ $data = <<readFromIniFile('test.ini');
+ $this->assertFalse(property_exists($config, 'invalid_key'));
+
+ @unlink('test.ini');
+ }
+}
diff --git a/tests/src/Orbit/ConsoleTest.php b/tests/src/Orbit/ConsoleTest.php
new file mode 100644
index 0000000..9944aa6
--- /dev/null
+++ b/tests/src/Orbit/ConsoleTest.php
@@ -0,0 +1,164 @@
+pushHandler(new \Monolog\Handler\TestHandler());
+
+ return $logger;
+ }
+
+ public function getTestLogRecords($logger): array
+ {
+ return $logger->getHandlers()[0]->getRecords();
+ }
+
+ public function getTestLogMessages($logger): array
+ {
+ $messages = [];
+ foreach ($this->getTestLogRecords($logger) as $message) {
+ $messages[] = $message['level_name'] . ": " . $message['message'];
+ }
+
+ return $messages;
+ }
+
+ public function makeConsole($argv = []): Console
+ {
+ $args = new \Qi_Console_ArgV(
+ $argv,
+ [
+ 'config|c:' => 'Use specified config file (.ini) for configuration',
+ 'host:' => 'Set host/ip address to listen on (default 0.0.0.0)',
+ 'port|p:' => 'Set port to listen on (default 1965)',
+ 'hostname:' => 'Set hostname of server (default localhost)',
+ 'tls-cert:' => 'Set cert PEM file to use (default null)',
+ 'tls-key:' => 'Set private key PEM file to use (default null)',
+ 'root-dir:' => 'Set the file root directory',
+ 'log:' => 'Set log filename (default orbit.log)',
+ 'dev' => 'Allow developer server functions (default false)',
+ 'help|h' => 'Show help',
+ 'verbose|v' => 'Include more verbose output',
+ 'quiet|q' => 'Print less messages',
+ 'no-color' => 'Don\'t use color output',
+ 'version' => 'Show version and exit',
+ ]
+ );
+ $terminal = new \Qi_Console_Terminal();
+ return new Console($args, $terminal);
+ }
+
+ public function testConstruct(): void
+ {
+ $args = new \Qi_Console_ArgV([]);
+ $terminal = new \Qi_Console_Terminal();
+ $console = new Console($args, $terminal);
+ $this->assertInstanceOf(Console::class, $console);
+ }
+
+ public function testExecuteNocolor(): void
+ {
+ $console = $this->makeConsole(['p', '--no-color', '--dev']);
+ $logger = $this->makeTestLogger();
+ ob_start();
+ $status = $console->execute($logger);
+ $output = ob_get_contents();
+ ob_get_clean();
+
+ $this->assertStringContainsString('Orbit // Gemini', $output);
+ }
+
+ public function testExecuteVersion(): void
+ {
+ $console = $this->makeConsole(['p', '--version', '--dev']);
+ $logger = $this->makeTestLogger();
+ ob_start();
+ $status = $console->execute($logger);
+ $output = ob_get_contents();
+ ob_get_clean();
+
+ $this->assertStringContainsString('Orbit 0.4', $output);
+ }
+
+ public function testExecuteHelp(): void
+ {
+ $console = $this->makeConsole(['p', '--help', '--dev']);
+ $logger = $this->makeTestLogger();
+ ob_start();
+ $status = $console->execute($logger);
+ $output = ob_get_contents();
+ ob_get_clean();
+
+ $this->assertStringContainsString('Orbit 0.4', $output);
+ }
+
+ public function testMakeConfigWithConfig(): void
+ {
+ $data = 'host=rainbow.muffin';
+ file_put_contents('test.ini', $data);
+
+ $console = $this->makeConsole(['p', '--config', 'test.ini', '--dev']);
+ $config = $console->makeConfig();
+ $this->assertSame('rainbow.muffin', $config->host);
+ @unlink('test.ini');
+ }
+
+ public function testMakeConfigSetValues(): void
+ {
+ $args = [
+ 'p', '--host=a', '--port=b', '--hostname=c', '--log=d',
+ '--verbose', '--root-dir=e', '--tls-cert=f', '--tls-key=g'
+ ];
+ $console = $this->makeConsole($args);
+ $config = $console->makeConfig();
+ $this->assertSame('a', $config->host);
+ $this->assertSame('b', $config->port);
+ $this->assertSame('c', $config->hostname);
+ $this->assertSame('d', $config->log_file);
+ $this->assertsame(100, $config->log_level);
+ $this->assertSame('e', $config->root_dir);
+ $this->assertSame('f', $config->tls_certfile);
+ $this->assertSame('g', $config->tls_keyfile);
+ }
+
+ public function testMakeLogger(): void
+ {
+ $config = new Config('dev');
+ $console = $this->makeConsole([]);
+ $logger = $console->makeLogger($config);
+ $this->assertInstanceOf(Logger::class, $logger);
+ // There should be two handlers attached
+ $this->assertEquals(2, count($logger->getHandlers()));
+ }
+
+ public function testMakeLoggerQuiet(): void
+ {
+ $config = new Config('dev');
+ $console = $this->makeConsole([]);
+ $logger = $console->makeLogger($config, true);
+ $this->assertInstanceOf(Logger::class, $logger);
+ // There should be one handler attached
+ $this->assertEquals(1, count($logger->getHandlers()));
+ }
+
+ public function tearDown(): void
+ {
+ @unlink('certs/localhost.cert.pem');
+ @unlink('certs/localhost.key.pem');
+ }
+}
diff --git a/tests/src/Orbit/RequestTest.php b/tests/src/Orbit/RequestTest.php
new file mode 100644
index 0000000..ec77b75
--- /dev/null
+++ b/tests/src/Orbit/RequestTest.php
@@ -0,0 +1,42 @@
+assertInstanceOf(Request::class, $request);
+ $this->assertSame('foo', $request->url);
+ $this->assertSame('foo', $request->path);
+ }
+
+ public function testConstructFullUrl(): void
+ {
+ $url = 'https://bob:haha@null.com:7000/x/y/z.bar?q=2&b=3#ok';
+ $request = new Request($url);
+ $this->assertInstanceOf(Request::class, $request);
+ $this->assertSame($url, $request->url);
+ $this->assertSame('https', $request->scheme);
+ $this->assertSame('bob', $request->user);
+ $this->assertSame('haha', $request->pass);
+ $this->assertSame('null.com', $request->host);
+ $this->assertSame('7000', $request->port);
+ $this->assertSame('/x/y/z.bar', $request->path);
+ $this->assertSame('q=2&b=3', $request->query);
+ $this->assertSame('ok', $request->fragment);
+ }
+
+ public function testGetUrlAppendPath(): void
+ {
+ $url = 'gemini://null.com/sub/?real=1';
+ $request = new Request($url);
+ $updated = $request->getUrlAppendPath('addition');
+ $this->assertSame('gemini://null.com/sub/addition?real=1', $updated);
+ }
+}
diff --git a/tests/src/Orbit/ResponseTest.php b/tests/src/Orbit/ResponseTest.php
new file mode 100644
index 0000000..59a3f96
--- /dev/null
+++ b/tests/src/Orbit/ResponseTest.php
@@ -0,0 +1,137 @@
+assertInstanceOf(Response::class, $response);
+ }
+
+ public function testGetHeader(): void
+ {
+ $response = new Response("20", "text/plain");
+ $this->assertSame("20 text/plain\r\n", $response->getHeader());
+ }
+
+ public function testSendWithBody(): void
+ {
+ $response = new Response("20", "text/plain");
+ $response->body = 'xyz';
+
+ $fp = fopen("php://memory", "w");
+ $result = $response->send($fp);
+
+ $this->assertSame(3, $result);
+ }
+
+ public function testSendWithFile(): void
+ {
+ file_put_contents('test.txt', 'hiho');
+ $response = new Response("20", "text/plain");
+ $response->filepath = 'test.txt';
+
+ $fp = fopen("php://memory", "w");
+ $result = $response->send($fp);
+
+ $this->assertSame(4, $result);
+ @unlink('test.txt');
+ }
+
+ public function testSendResourceBrokenPipe(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Failed to write to client");
+
+ $response = new Response("20", "text/plain");
+ $response->body = "123";
+
+ // Cannot write to this read-only stream resource
+ $fp = fopen("php://memory", "r");
+ $result = $response->send($fp);
+ }
+
+ public function testSendInvalidResource(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Invalid resource to write to");
+
+ $response = new Response("20", "text/plain");
+ $response->body = "123";
+
+ $fp = null;
+ $result = $response->send($fp);
+ }
+
+ public function testSendWithFileNoExists(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Error reading file");
+
+ $response = new Response("20", "text/plain");
+ $response->filepath = 'nofile.txt';
+
+ $fp = fopen("php://memory", "w");
+ $result = $response->send($fp);
+
+ $this->assertSame(4, $result);
+ }
+
+ public function testSendWithDirectory(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Cannot serve directory");
+
+ $dir = "test-aloof";
+ mkdir($dir);
+ $response = new Response("20", "text/plain");
+ $response->filepath = $dir;
+
+ $fp = fopen("php://memory", "w");
+ $result = $response->send($fp);
+ @rmdir($dir);
+ }
+
+ public function testSetBody(): void
+ {
+ $response = new Response("20", "text/plain");
+ $response->setBody('olive juice');
+
+ $body = $response->getBody();
+ $this->assertEquals('olive juice', $body);
+ }
+
+ public function testGetBodyWithFile(): void
+ {
+ file_put_contents('elem.gmi', '# Hi there');
+ $response = new Response("20", "text/plain");
+ $response->setStaticFile('elem.gmi');
+
+ $body = $response->getBody();
+ $this->assertEquals('# Hi there', $body);
+
+ unlink('elem.gmi');
+ }
+
+ public function testSetStatusSetMeta(): void
+ {
+ $response = new Response();
+ $response->setStatus(20);
+ $response->setMeta('text/gemini');
+
+ $header = $response->getHeader();
+ $this->assertEquals("20 text/gemini\r\n", $header);
+
+ }
+
+ public function tearDown(): void
+ {
+ @rmdir('test-aloof');
+ }
+}
diff --git a/tests/src/Orbit/ServerTest.php b/tests/src/Orbit/ServerTest.php
new file mode 100644
index 0000000..fdca51e
--- /dev/null
+++ b/tests/src/Orbit/ServerTest.php
@@ -0,0 +1,280 @@
+pushHandler(new \Monolog\Handler\TestHandler());
+
+ return $logger;
+ }
+
+ public function getTestLogRecords($logger): array
+ {
+ return $logger->getHandlers()[0]->getRecords();
+ }
+
+ public function getTestLogMessages($logger): array
+ {
+ $messages = [];
+ foreach ($this->getTestLogRecords($logger) as $message) {
+ $messages[] = $message['level_name'] . ": " . $message['message'];
+ }
+
+ return $messages;
+ }
+
+ public function testConstructFailsWithoutConfig(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Missing required cert file");
+
+ $config = new Config();
+ $server = new Server($config);
+ }
+
+ public function testConstructAutoCert(): void
+ {
+ $config = new Config(true);
+ $server = new Server($config);
+ $this->assertInstanceOf(Server::class, $server);
+ $this->assertEquals(1, count($server->getLogger()->getHandlers()));
+ }
+
+ public function testConstructSetLogger(): void
+ {
+ $config = new Config(true);
+ $logger = new Logger('orbit-test');
+ $server = new Server($config, null, $logger);
+
+ $l = $server->getLogger();
+ $this->assertSame($l, $logger);
+ $this->assertEquals(0, count($l->getHandlers()));
+ }
+
+ public function testConstructSetCert(): void
+ {
+ $config = new Config(true);
+ $cert = new Cert($config);
+ $server = new Server($config, $cert);
+
+ $this->assertSame($cert, $server->cert);
+ }
+
+ public function testConstructSetsTimeout(): void
+ {
+ ini_set("default_socket_timeout", "32");
+
+ $server = new Server(new Config(true));
+ $this->assertEquals(32, $server->timeout);
+ }
+
+ public function testIntakeConnectionsMustBeResource(): void
+ {
+ $this->expectException(\Exception::class);
+ $this->expectExceptionMessage("Stream server must be resource object");
+
+ $server = new Server(new Config(true));
+
+ $s = new \stdClass();
+ $result = $server->intakeConnections($s);
+ }
+
+ public function testValidateRequestMustContainHost(): void
+ {
+ $request = new Request('');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertFalse($is_valid);
+ $this->assertSame(Response::STATUS_BAD_REQUEST, $response->status);
+ $this->assertStringContainsString("Bad request", $response->meta);
+ }
+
+ public function testValidateRequestMustHaveCorrectScheme(): void
+ {
+ $request = new Request('https://foo.bar/');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertFalse($is_valid);
+ $this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status);
+ $this->assertStringContainsString("unsupported scheme", $response->meta);
+ }
+
+ public function testValidateRequestMustHaveCorrectHost(): void
+ {
+ $request = new Request('gemini://superfly.com/');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertFalse($is_valid);
+ $this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status);
+ $this->assertStringContainsString("invalid host", $response->meta);
+ }
+
+ public function testValidateRequestMustHaveCorrectPort(): void
+ {
+ $request = new Request('gemini://localhost:8080/');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertFalse($is_valid);
+ $this->assertSame(Response::STATUS_PROXY_REQUEST_REFUSED, $response->status);
+ $this->assertStringContainsString("invalid port", $response->meta);
+ }
+
+ public function testValidateRequestCanIncludePort(): void
+ {
+ $request = new Request('gemini://localhost:1965/');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertTrue($is_valid);
+ $this->assertSame('', $response->status);
+ }
+
+ public function testValidateRequestMustNotBeMoreThanLimit(): void
+ {
+ $url = 'gemini://localhost/';
+ $request = new Request($url . str_repeat("x", 1025 - strlen($url)));
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertFalse($is_valid);
+ $this->assertSame(Response::STATUS_BAD_REQUEST, $response->status);
+ $this->assertStringContainsString("too long", $response->meta);
+ }
+
+ public function testValidateRequestCanBeAtLimit(): void
+ {
+ $url = 'gemini://localhost/';
+ $request = new Request($url . str_repeat("x", 1024 - strlen($url)));
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertTrue($is_valid);
+ $this->assertSame('', $response->status);
+ }
+
+ public function testValidateRequestMustNotContainNonUtf8Bytes(): void
+ {
+ $request = new Request('gemini://localhost/' . chr(240) . chr(159) . chr(144) . chr(152));
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertFalse($is_valid);
+ $this->assertSame(Response::STATUS_BAD_REQUEST, $response->status);
+ $this->assertStringContainsString("non-UTF8", $response->meta);
+ }
+
+ public function testValidateRequestLocalhostIsValid(): void
+ {
+ $request = new Request('gemini://localhost/');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertTrue($is_valid);
+ $this->assertSame('', $response->status);
+ }
+
+ public function testValidateRequestLoopbackAddrIsValid(): void
+ {
+ $request = new Request('gemini://127.0.0.1/');
+
+ $server = new Server(new Config(true));
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertTrue($is_valid);
+ $this->assertSame('', $response->status);
+ }
+
+ public function testValidateRequestMatchesConfigHostname(): void
+ {
+ $request = new Request('gemini://dogdays.dev/');
+
+ $config = new Config(true);
+ $config->hostname = 'dogdays.dev';
+ $server = new Server($config);
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertTrue($is_valid);
+ $this->assertSame('', $response->status);
+ @unlink("certs/dogdays.dev.cert.pem");
+ @unlink("certs/dogdays.dev.key.pem");
+ }
+
+ public function testValidateRequestMatchesConfigPort(): void
+ {
+ $request = new Request('gemini://localhost:1972/');
+
+ $config = new Config(true);
+ $config->port = '1972';
+ $server = new Server($config);
+ [$is_valid, $response] = $server->validateRequest($request);
+
+ $this->assertTrue($is_valid);
+ $this->assertSame('', $response->status);
+ }
+
+ public function testOnWarningTimedOut(): void
+ {
+ $logger = $this->makeTestLogger();
+ $server = new Server(new Config(true), null, $logger);
+
+ $result = $server->onWarning(1, 'Connection timed out');
+ $this->assertTrue($result);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertNotContains('Error while accepting', $messages);
+ }
+
+ public function testOnWarningError(): void
+ {
+ $logger = $this->makeTestLogger();
+ $server = new Server(new Config(true), null, $logger);
+
+ $result = $server->onWarning(1, 'Something terrible happened');
+ $this->assertTrue($result);
+ $messages = $this->getTestLogMessages($logger);
+ $this->assertStringContainsString('Error while accepting connection', $messages[3]);
+ }
+
+ public function testGetListenAddress(): void
+ {
+ $config = new Config(true);
+ $config->host = '127.0.0.1';
+ $config->port = 1888;
+ $server = new Server($config);
+
+ $address = $server->getListenAddress();
+
+ $this->assertSame('tls://127.0.0.1:1888', $address);
+ }
+
+ public function tearDown(): void
+ {
+ @unlink("certs/localhost.cert.pem");
+ @unlink("certs/localhost.key.pem");
+ }
+}
diff --git a/tests/src/Orbit/UtilTest.php b/tests/src/Orbit/UtilTest.php
new file mode 100644
index 0000000..5843ebf
--- /dev/null
+++ b/tests/src/Orbit/UtilTest.php
@@ -0,0 +1,48 @@
+assertStringContainsString("61 61", $result);
+ $this->assertStringContainsString("aa", $result);
+ }
+
+ public function testHexViewSpecialChars(): void
+ {
+ $result = Util::hexView("√√ç∂¥˙˚ˆø©ååœ");
+
+ $this->assertStringContainsString("E2 88 9A E2 88 9A C3 A7 E2 88 82 C2 A5 CB 99 CB", $result);
+ $this->assertStringContainsString("9A CB 86 C3 B8 C2 A9 C3 A5 C3 A5 C5 93", $result);
+ }
+
+ public function testHexViewNonPrintableChars(): void
+ {
+ $result = Util::hexView(chr(1) . chr(2) . chr(3) . chr(27));
+
+ $this->assertStringContainsString("01 02 03 1B", $result);
+ }
+
+ public function testHexViewMultibyteChars(): void
+ {
+ $result = Util::hexView("0א0ڃ");
+
+ $this->assertStringContainsString("30 D7 90 30 DA 83", $result);
+ $this->assertStringContainsString("0..0", $result);
+ }
+
+ public function testHexViewEmoji(): void
+ {
+ $result = Util::hexView("🍪");
+
+ $this->assertStringContainsString("F0 9F 8D AA", $result);
+ }
+}