diff --git a/README.md b/README.md
index 002c325..e6786c4 100644
--- a/README.md
+++ b/README.md
@@ -143,10 +143,10 @@ $uri = "serverquery://username:password@[fe80::250:56ff:fe16:1447]:10022/?ssh=1"
#### SSL/TLS Connections ([TeaSpeak Server](https://www.teaspeak.de) only)
-Secure ServerQuery connections can be established using the optional `tls` parameter:
+Secure TeaSpeak ServerQuery connections can be established using the optional `tls` parameter. Certificate verification remains disabled by default for compatibility with self-signed deployments. Enable `tls_verify` only when the server certificate is trusted by PHP and matches the hostname:
```php
-$uri = "serverquery://username:password@[fe80::250:56ff:fe16:1447]:10011/?tls=1";
+$uri = "serverquery://username:password@teaspeak.example.com:10011/?tls=1&tls_verify=1";
```
#### Custom Protocol Welcome Message and/or MOTD ([TeaSpeak Server](https://www.teaspeak.de) only)
diff --git a/src/Adapter/FileTransfer.php b/src/Adapter/FileTransfer.php
index bad936f..975ec39 100644
--- a/src/Adapter/FileTransfer.php
+++ b/src/Adapter/FileTransfer.php
@@ -38,6 +38,8 @@ public function __destruct()
if ($this->getTransport() instanceof Transport && $this->getTransport()->isConnected()) {
$this->getTransport()->disconnect();
}
+
+ Profiler::remove(spl_object_hash($this));
}
/**
@@ -105,54 +107,55 @@ public function upload(string $ftkey, int $seek, string $data): void
*/
public function download(string $ftkey, int $size, bool $passthru = false)
{
- $this->init($ftkey);
-
if ($passthru) {
- $this->passthru($size);
+ $this->downloadTo($ftkey, $size, static function (StringHelper $data): void {
+ echo $data;
+ });
return;
}
$buff = new StringHelper("");
- $pack = 4096;
-
- Signal::getInstance()->emit("filetransferDownloadStarted", $ftkey, count($buff), $size);
-
- for ($seek = 0; $seek < $size;) {
- $rest = $size - $seek;
- $pack = min($rest, $pack);
- $data = $this->getTransport()->read(min($rest, $pack));
- $seek = $seek + $pack;
-
+ $this->downloadTo($ftkey, $size, static function (StringHelper $data) use ($buff): void {
$buff->append($data);
-
- Signal::getInstance()->emit("filetransferDownloadProgress", $ftkey, count($buff), $size);
- }
-
- $this->getProfiler()->stop();
-
- Signal::getInstance()->emit("filetransferDownloadFinished", $ftkey, count($buff), $size);
-
- if (strlen($buff) != $size) {
- throw new FileTransferException("incomplete file download (" . count($buff) . " of " . $size . " bytes)");
- }
+ });
return $buff;
}
/**
- * Outputs all remaining data on a TeamSpeak 3 file transfer stream using PHP's fpassthru()
- * function.
+ * Downloads a file and passes each received chunk to the given consumer.
*
+ * @param string $ftkey
* @param integer $size
+ * @param callable $consumer
* @return void
* @throws FileTransferException
*/
- protected function passthru(int $size): void
+ public function downloadTo(string $ftkey, int $size, callable $consumer): void
{
- $buff_size = fpassthru($this->getTransport()->getStream());
+ $this->init($ftkey);
+ $pack = 4096;
+ $seek = 0;
+
+ Signal::getInstance()->emit("filetransferDownloadStarted", $ftkey, $seek, $size);
+
+ try {
+ while ($seek < $size) {
+ $data = $this->getTransport()->read(min($size - $seek, $pack));
- if ($buff_size != $size) {
- throw new FileTransferException("incomplete file download (" . $buff_size . " of " . $size . " bytes)");
+ if (count($data) === 0) {
+ throw new FileTransferException("incomplete file download (" . $seek . " of " . $size . " bytes)");
+ }
+
+ $consumer($data);
+ $seek += count($data);
+
+ Signal::getInstance()->emit("filetransferDownloadProgress", $ftkey, $seek, $size);
+ }
+ } finally {
+ $this->getProfiler()->stop();
}
+
+ Signal::getInstance()->emit("filetransferDownloadFinished", $ftkey, $seek, $size);
}
}
diff --git a/src/Adapter/ServerQuery.php b/src/Adapter/ServerQuery.php
index dde4306..3ae2aec 100644
--- a/src/Adapter/ServerQuery.php
+++ b/src/Adapter/ServerQuery.php
@@ -92,6 +92,7 @@ public function __destruct()
{
// do not disconnect, when acting as bot in non-blocking mode
if (! $this->getTransport()->getConfig("blocking")) {
+ Profiler::remove(spl_object_hash($this));
return;
}
@@ -99,9 +100,11 @@ public function __destruct()
try {
$this->request("quit");
} catch (AdapterException) {
- return;
+ // The transport destructor will close an unavailable connection.
}
}
+
+ Profiler::remove(spl_object_hash($this));
}
/**
diff --git a/src/Adapter/ServerQuery/Reply.php b/src/Adapter/ServerQuery/Reply.php
index 0209d99..80ea6e3 100644
--- a/src/Adapter/ServerQuery/Reply.php
+++ b/src/Adapter/ServerQuery/Reply.php
@@ -86,7 +86,7 @@ public function __construct(array $rpl, string $cmd = "", Host $con = null, bool
*/
public function toString(): ?StringHelper
{
- return (!func_num_args()) ? $this->rpl->unescape() : $this->rpl;
+ return (!func_num_args()) ? (new StringHelper($this->rpl->toString()))->unescape() : $this->rpl;
}
/**
diff --git a/src/Helper/Char.php b/src/Helper/Char.php
index 42ed5ab..6bc7878 100644
--- a/src/Helper/Char.php
+++ b/src/Helper/Char.php
@@ -199,11 +199,11 @@ public function toHex(): string
*/
public static function fromHex(string $hex): Char
{
- if (strlen($hex) != 2) {
+ if (strlen($hex) != 2 || !ctype_xdigit($hex)) {
throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number");
}
- return new self(chr(hexdec($hex)));
+ return new self(hex2bin($hex));
}
/**
diff --git a/src/Helper/Profiler.php b/src/Helper/Profiler.php
index ec8afda..6e63359 100644
--- a/src/Helper/Profiler.php
+++ b/src/Helper/Profiler.php
@@ -30,6 +30,17 @@ public static function init(string $name = "default"): void
self::$timers[$name] = new Timer($name);
}
+ /**
+ * Removes a timer which is no longer associated with a live adapter.
+ *
+ * @param string $name
+ * @return void
+ */
+ public static function remove(string $name): void
+ {
+ unset(self::$timers[$name]);
+ }
+
/**
* Starts a timer.
*
diff --git a/src/Helper/Signal.php b/src/Helper/Signal.php
index 7fde1b5..b957260 100644
--- a/src/Helper/Signal.php
+++ b/src/Helper/Signal.php
@@ -172,6 +172,16 @@ public function clearHandlers(string $signal): void
}
}
+ /**
+ * Clears all registered signal handlers.
+ *
+ * @return void
+ */
+ public function clearAllHandlers(): void
+ {
+ $this->sigslots = [];
+ }
+
/**
* Returns a singleton instance of PlanetTeamSpeak\TeamSpeak3Framework\Helper\Signal.
*
diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php
index 7feb525..d90bdd9 100644
--- a/src/Helper/StringHelper.php
+++ b/src/Helper/StringHelper.php
@@ -381,8 +381,13 @@ public function isInt(): bool
*/
public function toInt(): int
{
- if ($this->string == pow(2, 63) || $this->string == pow(2, 64) || $this->string > pow(2, 31)) {
- return -1;
+ if (ctype_digit($this->string)) {
+ $value = ltrim($this->string, "0") ?: "0";
+ $max = (string)PHP_INT_MAX;
+
+ if (strlen($value) > strlen($max) || (strlen($value) === strlen($max) && strcmp($value, $max) > 0)) {
+ return -1;
+ }
}
return intval($this->string);
@@ -426,21 +431,7 @@ public function toSha1(): string
*/
public function isUtf8(): bool
{
- if (preg_match('/\\A[\\x00-\\x7F]*\\z/', $this->string)) {
- return true;
- }
-
- $pattern = [];
-
- $pattern[] = "[\xC2-\xDF][\x80-\xBF]"; // non-overlong 2-byte
- $pattern[] = "\xE0[\xA0-\xBF][\x80-\xBF]"; // excluding overlongs
- $pattern[] = "[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}"; // straight 3-byte
- $pattern[] = "\xED[\x80-\x9F][\x80-\xBF]"; // excluding surrogates
- $pattern[] = "\xF0[\x90-\xBF][\x80-\xBF]{2}"; // planes 1-3
- $pattern[] = "[\xF1-\xF3][\x80-\xBF]{3}"; // planes 4-15
- $pattern[] = "\xF4[\x80-\x8F][\x80-\xBF]{2}"; // plane 16
-
- return (bool)preg_match("%(?:" . implode("|", $pattern) . ")+%xs", $this->string);
+ return mb_check_encoding($this->string, 'UTF-8');
}
/**
@@ -472,10 +463,17 @@ public function toBase64(): string
*
* @param string $base64
* @return self
+ * @throws HelperException
*/
public static function fromBase64(string $base64): StringHelper
{
- return new self(base64_decode($base64));
+ $string = base64_decode($base64, true);
+
+ if ($string === false) {
+ throw new HelperException("given parameter is not valid base64 data");
+ }
+
+ return new self($string);
}
/**
@@ -485,13 +483,7 @@ public static function fromBase64(string $base64): StringHelper
*/
public function toHex(): string
{
- $hex = "";
-
- foreach ($this as $char) {
- $hex .= $char->toHex();
- }
-
- return $hex;
+ return strtoupper(bin2hex($this->string));
}
/**
@@ -503,17 +495,11 @@ public function toHex(): string
*/
public static function fromHex(string $hex): StringHelper
{
- $string = "";
-
- if (strlen($hex) % 2 == 1) {
+ if (strlen($hex) % 2 == 1 || ($hex !== "" && !ctype_xdigit($hex))) {
throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number");
}
- foreach (str_split($hex, 2) as $chunk) {
- $string .= chr(hexdec($chunk));
- }
-
- return new self($string);
+ return new self(hex2bin($hex));
}
/**
diff --git a/src/Helper/Uri.php b/src/Helper/Uri.php
index cbc6c01..e910f7f 100644
--- a/src/Helper/Uri.php
+++ b/src/Helper/Uri.php
@@ -511,7 +511,7 @@ public function getQueryVar(string $key, mixed $default = null): mixed
return $default;
}
- parse_str(rawurldecode($this->query), $queryArray);
+ parse_str($this->query, $queryArray);
if (array_key_exists($key, $queryArray)) {
$val = $queryArray[$key];
@@ -585,7 +585,7 @@ public function getFragment(mixed $default = null): ?StringHelper
*/
public static function getUserParam(string $key, mixed $default = null): mixed
{
- return (array_key_exists($key, $_REQUEST) && !empty($_REQUEST[$key])) ? self::stripslashesRecursive($_REQUEST[$key]) : $default;
+ return array_key_exists($key, $_REQUEST) ? self::stripslashesRecursive($_REQUEST[$key]) : $default;
}
/**
@@ -597,7 +597,7 @@ public static function getUserParam(string $key, mixed $default = null): mixed
*/
public static function getHostParam(string $key, mixed $default = null): mixed
{
- return (array_key_exists($key, $_SERVER) && !empty($_SERVER[$key])) ? $_SERVER[$key] : $default;
+ return array_key_exists($key, $_SERVER) ? $_SERVER[$key] : $default;
}
/**
@@ -609,7 +609,7 @@ public static function getHostParam(string $key, mixed $default = null): mixed
*/
public static function getSessParam(string $key, mixed $default = null): mixed
{
- return (array_key_exists($key, $_SESSION) && !empty($_SESSION[$key])) ? $_SESSION[$key] : $default;
+ return array_key_exists($key, $_SESSION) ? $_SESSION[$key] : $default;
}
/**
diff --git a/src/Node/Channel.php b/src/Node/Channel.php
index 734a867..6a8ccbd 100644
--- a/src/Node/Channel.php
+++ b/src/Node/Channel.php
@@ -71,7 +71,7 @@ public function subChannelGetById(int $cid): Channel
throw new ServerQueryException("invalid channelID", 0x300);
}
- return $this->channelList[$cid];
+ return $this->subChannelList()[$cid];
}
/**
@@ -81,7 +81,7 @@ public function subChannelGetById(int $cid): Channel
* @return Channel
* @throws ServerQueryException
*/
- public function subChannelGetByName(int $name): Channel
+ public function subChannelGetByName(string $name): Channel
{
foreach ($this->subChannelList() as $channel) {
if ($channel["channel_name"] == $name) {
@@ -124,7 +124,7 @@ public function clientGetById(int $clid): Client
throw new ServerQueryException("invalid clientID", 0x200);
}
- return $this->clientList[$clid];
+ return $this->clientList()[$clid];
}
/**
@@ -134,7 +134,7 @@ public function clientGetById(int $clid): Client
* @return Client
* @throws ServerQueryException
*/
- public function clientGetByName(int $name): Client
+ public function clientGetByName(string $name): Client
{
foreach ($this->clientList() as $client) {
if ($client["client_nickname"] == $name) {
@@ -392,7 +392,7 @@ public function iconDownload()
return;
}
- $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("channel_icon_id"));
+ $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("channel_icon_id"));
$transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]);
return $transfer->download($download["ftkey"], $download["size"]);
diff --git a/src/Node/Client.php b/src/Node/Client.php
index 90397bc..6c4d8e2 100644
--- a/src/Node/Client.php
+++ b/src/Node/Client.php
@@ -304,7 +304,7 @@ public function avatarDownload()
return;
}
- $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->avatarGetName());
+ $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->avatarGetName());
$transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]);
return $transfer->download($download["ftkey"], $download["size"]);
@@ -398,7 +398,7 @@ public function iconDownload()
return;
}
- $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("client_icon_id"));
+ $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("client_icon_id"));
$transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]);
return $transfer->download($download["ftkey"], $download["size"]);
diff --git a/src/Node/Group.php b/src/Node/Group.php
index e09d3a8..f5c3fe7 100644
--- a/src/Node/Group.php
+++ b/src/Node/Group.php
@@ -51,7 +51,7 @@ public function iconDownload()
return;
}
- $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("iconid"));
+ $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("iconid"));
$transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]);
return $transfer->download($download["ftkey"], $download["size"]);
diff --git a/src/Node/Node.php b/src/Node/Node.php
index e99e392..a367f47 100644
--- a/src/Node/Node.php
+++ b/src/Node/Node.php
@@ -227,23 +227,28 @@ public function getViewer(ViewerInterface $viewer): string
protected function filterList(array $nodes = [], array $rules = []): array
{
if (!empty($rules)) {
- foreach ($nodes as $node) {
+ foreach ($nodes as $nodeKey => $node) {
if (!$node instanceof Node) {
continue;
}
$props = $node->getInfo(false);
- $props = array_intersect_key($props, $rules);
- foreach ($props as $key => $val) {
+ foreach ($rules as $key => $rule) {
+ if (!array_key_exists($key, $props)) {
+ unset($nodes[$nodeKey]);
+ break;
+ }
+
+ $val = $props[$key];
if ($val instanceof StringHelper) {
- $match = $val->contains($rules[$key], true);
+ $match = $val->contains($rule, true);
} else {
- $match = $val == $rules[$key];
+ $match = $val == $rule;
}
if ($match === false) {
- unset($nodes[$node->getId()]);
+ unset($nodes[$nodeKey]);
}
}
}
diff --git a/src/Node/Server.php b/src/Node/Server.php
index ba7a29d..30f3093 100644
--- a/src/Node/Server.php
+++ b/src/Node/Server.php
@@ -1828,7 +1828,7 @@ public function iconDownload(string $iconname = null)
$name = $this->iconGetName("virtualserver_icon_id");
}
- $download = $this->transferInitDownload(rand(0x0000, 0xFFFF), 0, $name);
+ $download = $this->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $name);
$transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]);
return $transfer->download($download["ftkey"], $download["size"]);
@@ -1848,7 +1848,7 @@ public function iconUpload(string $data): int
$crc = crc32($data);
$size = strlen($data);
- $upload = $this->transferInitUpload(rand(0x0000, 0xFFFF), 0, "/icon_" . $crc, $size);
+ $upload = $this->transferInitUpload(TeamSpeak3::generateTransferClientId(), 0, "/icon_" . $crc, $size);
$transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($upload["host"], ":") ? "[" . $upload["host"] . "]" : $upload["host"]) . ":" . $upload["port"]);
$transfer->upload($upload["ftkey"], $upload["seekpos"], $data);
diff --git a/src/TeamSpeak3.php b/src/TeamSpeak3.php
index e00705d..0a6b115 100644
--- a/src/TeamSpeak3.php
+++ b/src/TeamSpeak3.php
@@ -49,6 +49,17 @@
*/
class TeamSpeak3
{
+ /**
+ * Generates a client-side identifier for a file transfer.
+ *
+ * @return int
+ * @throws \Random\RandomException
+ */
+ public static function generateTransferClientId(): int
+ {
+ return random_int(0x0000, 0xFFFF);
+ }
+
/**
* TeamSpeak 3 protocol welcome message.
*/
@@ -326,6 +337,7 @@ class TeamSpeak3
* - timeout
* - blocking
* - tls (TeaSpeak only)
+ * - tls_verify
* - ssh (TeamSpeak only)
* - nickname
* - no_query_clients
@@ -343,7 +355,7 @@ class TeamSpeak3
* === URI Examples ===
* - serverquery://127.0.0.1:10011/
* - serverquery://127.0.0.1:10022/?ssh=1 (TeamSpeak ONLY)
- * - serverquery://127.0.0.1:10011/?tls=1 (TeaSpeak ONLY)
+ * - serverquery://teaspeak.example.com:10011/?tls=1&tls_verify=1 (TeaSpeak ONLY)
* - serverquery://127.0.0.1:10022/?ssh=1&server_port=9987
* - serverquery://127.0.0.1:10011/?server_port=9987&channel_id=1
* - serverquery://127.0.0.1:10011/?server_port=9987&channel_id=1#no_query_clients
@@ -364,7 +376,7 @@ public static function factory(string $uri): Host|Server|ServerQuery|MockServerQ
$uri = new Uri($uri);
$adapter = self::getAdapterName($uri->getScheme());
- $options = ["host" => $uri->getHost(), "port" => $uri->getPort(), "timeout" => (int)$uri->getQueryVar("timeout", 10), "blocking" => (int)$uri->getQueryVar("blocking", 1), "tls" => (int)$uri->getQueryVar("tls", 0), "ssh" => (int)$uri->getQueryVar("ssh", 0)];
+ $options = ["host" => $uri->getHost(), "port" => $uri->getPort(), "timeout" => (int)$uri->getQueryVar("timeout", 10), "blocking" => (int)$uri->getQueryVar("blocking", 1), "tls" => (int)$uri->getQueryVar("tls", 0), "tls_verify" => (int)$uri->getQueryVar("tls_verify", 0), "ssh" => (int)$uri->getQueryVar("ssh", 0)];
self::loadClass($adapter);
diff --git a/src/Transport/TCP.php b/src/Transport/TCP.php
index b591ab3..a70d3cd 100644
--- a/src/Transport/TCP.php
+++ b/src/Transport/TCP.php
@@ -35,7 +35,8 @@ public function connect(): void
if (empty($this->config["ssh"])) {
$address = "tcp://" . (str_contains($host, ":") ? "[" . $host . "]" : $host) . ":" . $port;
- $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => true, "verify_peer" => false, "verify_peer_name" => false]];
+ $verify = !empty($this->config["tls_verify"]);
+ $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => !$verify, "verify_peer" => $verify, "verify_peer_name" => $verify]];
$errno = 0;
$errstr = '';
@@ -47,7 +48,10 @@ public function connect(): void
}
if (!empty($this->config["tls"])) {
- stream_socket_enable_crypto($this->stream, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT);
+ if (!$this->enableCrypto()) {
+ $this->disconnect();
+ throw new TransportException("failed to enable TLS for server '$host:$port'");
+ }
}
} else {
$this->session = @ssh2_connect($host, $port);
@@ -81,6 +85,16 @@ protected function openSocket(string $address, int &$errno, string &$errstr, int
return @stream_socket_client($address, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, stream_context_create($options));
}
+ /**
+ * Enables TLS encryption on the connected stream.
+ *
+ * @return bool
+ */
+ protected function enableCrypto(): bool
+ {
+ return stream_socket_enable_crypto($this->stream, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT);
+ }
+
/**
* Disconnects from a remote server.
*
@@ -92,12 +106,18 @@ public function disconnect(): void
return;
}
+ if (is_resource($this->stream)) {
+ @fclose($this->stream);
+ }
+
$this->stream = null;
if (is_resource($this->session)) {
@ssh2_disconnect($this->session);
}
+ $this->session = null;
+
Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "Disconnected");
}
@@ -118,7 +138,8 @@ public function read(int $length = 4096): StringHelper
Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataRead", $data);
- if ($data === false) {
+ if ($data === false || ($data === "" && feof($this->stream))) {
+ $this->disconnect();
throw new TransportException("connection to server '" . $this->config["host"] . ":" . $this->config["port"] . "' lost");
}
@@ -185,11 +206,32 @@ public function send(string $data): void
{
$this->connect();
- @fwrite($this->stream, $data);
+ $length = strlen($data);
+ $written = 0;
+
+ while ($written < $length) {
+ $result = $this->write(substr($data, $written));
+
+ if ($result === false || $result === 0) {
+ throw new TransportException("failed to write to server '" . $this->config["host"] . ":" . $this->config["port"] . "'");
+ }
+
+ $written += $result;
+ }
Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataSend", $data);
}
+ /**
+ * Writes a chunk to the underlying stream.
+ *
+ * @return int|false
+ */
+ protected function write(string $data): int|false
+ {
+ return @fwrite($this->stream, $data);
+ }
+
/**
* Writes a line of data to the stream.
*
@@ -204,6 +246,11 @@ public function sendLine(string $data, string $separator = "\n"): void
$size = strlen($data);
$pack = 4096;
+ if ($size === 0) {
+ $this->send($separator);
+ return;
+ }
+
for ($seek = 0; $seek < $size;) {
$rest = $size - $seek;
$pack = min($rest, $pack);
diff --git a/src/Transport/Transport.php b/src/Transport/Transport.php
index 63cf07c..48949a6 100644
--- a/src/Transport/Transport.php
+++ b/src/Transport/Transport.php
@@ -68,6 +68,10 @@ public function __construct(array $config)
$config["blocking"] = 1;
}
+ if (!array_key_exists("tls_verify", $config)) {
+ $config["tls_verify"] = 0;
+ }
+
$this->config = $config;
return $this;
}
@@ -154,9 +158,9 @@ public function getStream()
*
* @param string|null $key
* @param mixed|null $default
- * @return array|string
+ * @return mixed
*/
- public function getConfig(string $key = null, mixed $default = null): array|string|int
+ public function getConfig(string $key = null, mixed $default = null): mixed
{
if ($key !== null) {
return array_key_exists($key, $this->config) ? $this->config[$key] : $default;
@@ -240,16 +244,20 @@ protected function waitForReadyRead(int $time = 0): void
return;
}
- do {
- $read = [$this->stream];
- $null = null;
+ $read = [$this->stream];
+ $null = null;
+ $result = @stream_select($read, $null, $null, $this->config["timeout"]);
- if ($time) {
- Signal::getInstance()
- ->emit(strtolower($this->getAdapterType()) . "WaitTimeout", $time, $this->getAdapter());
- }
+ if ($result === false) {
+ throw new TransportException("unable to wait for data from server '" . $this->config["host"] . ":" . $this->config["port"] . "'");
+ }
- $time = $time + $this->config["timeout"];
- } while (@stream_select($read, $null, $null, $this->config["timeout"]) == 0);
+ if ($result === 0) {
+ $time = $time ?: $this->config["timeout"];
+ Signal::getInstance()
+ ->emit(strtolower($this->getAdapterType()) . "WaitTimeout", $time, $this->getAdapter());
+
+ throw new TransportException("timed out waiting for data from server '" . $this->config["host"] . ":" . $this->config["port"] . "'");
+ }
}
}
diff --git a/src/Transport/UDP.php b/src/Transport/UDP.php
index e996594..5902320 100644
--- a/src/Transport/UDP.php
+++ b/src/Transport/UDP.php
@@ -69,6 +69,10 @@ public function disconnect(): void
return;
}
+ if (is_resource($this->stream)) {
+ @fclose($this->stream);
+ }
+
$this->stream = null;
Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "Disconnected");
@@ -108,8 +112,22 @@ public function send(string $data): void
{
$this->connect();
- @stream_socket_sendto($this->stream, $data);
+ $written = $this->sendTo($data);
+
+ if ($written === false || $written !== strlen($data)) {
+ throw new TransportException("failed to write to server '" . $this->config["host"] . ":" . $this->config["port"] . "'");
+ }
Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataSend", $data);
}
+
+ /**
+ * Sends a datagram through the underlying stream.
+ *
+ * @return int|false
+ */
+ protected function sendTo(string $data): int|false
+ {
+ return @stream_socket_sendto($this->stream, $data);
+ }
}
diff --git a/src/Viewer/Html.php b/src/Viewer/Html.php
index 896a8ab..87df1f4 100644
--- a/src/Viewer/Html.php
+++ b/src/Viewer/Html.php
@@ -400,7 +400,7 @@ protected function getSuffixIconServer(): string
if ($this->currObj["virtualserver_icon_id"]) {
if (!$this->currObj->iconIsLocal("virtualserver_icon_id") && $this->ftclient) {
if (!isset($this->cacheIcon[$this->currObj["virtualserver_icon_id"]])) {
- $download = $this->currObj->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("virtualserver_icon_id"));
+ $download = $this->currObj->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("virtualserver_icon_id"));
if ($this->ftclient == "data:image") {
$download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]);
@@ -460,7 +460,7 @@ protected function getSuffixIconChannel(): string
if ($this->currObj["channel_icon_id"]) {
if (!$this->currObj->iconIsLocal("channel_icon_id") && $this->ftclient) {
if (!isset($this->cacheIcon[$this->currObj["channel_icon_id"]])) {
- $download = $this->currObj->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("channel_icon_id"));
+ $download = $this->currObj->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("channel_icon_id"));
if ($this->ftclient == "data:image") {
$download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]);
@@ -522,7 +522,7 @@ protected function getSuffixIconClient(): string
if (!$group->iconIsLocal("iconid") && $this->ftclient) {
if (!isset($this->cacheIcon[$group["iconid"]])) {
- $download = $group->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $group->iconGetName("iconid"));
+ $download = $group->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $group->iconGetName("iconid"));
if ($this->ftclient == "data:image") {
$download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]);
@@ -546,7 +546,7 @@ protected function getSuffixIconClient(): string
if ($this->currObj["client_icon_id"]) {
if (!$this->currObj->iconIsLocal("client_icon_id") && $this->ftclient) {
if (!isset($this->cacheIcon[$this->currObj["client_icon_id"]])) {
- $download = $this->currObj->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("client_icon_id"));
+ $download = $this->currObj->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("client_icon_id"));
if ($this->ftclient == "data:image") {
$download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]);
@@ -611,6 +611,9 @@ protected function getImage(string $name, string $text = "", string $class = nul
$src = $this->flagpath;
}
- return "
";
+ $src = htmlspecialchars($src . $name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+ $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
+
+ return "
";
}
}
diff --git a/tests/Adapter/FileTransferTest.php b/tests/Adapter/FileTransferTest.php
new file mode 100644
index 0000000..8b8ed21
--- /dev/null
+++ b/tests/Adapter/FileTransferTest.php
@@ -0,0 +1,81 @@
+ 'test', 'port' => 12345]) extends FileTransfer {
+ public function syn(): void
+ {
+ $this->transport = new class (['host' => 'test', 'port' => 12345]) extends Transport {
+ private array $chunks = ['ab', 'cde'];
+
+ public function connect(): void
+ {
+ }
+
+ public function disconnect(): void
+ {
+ }
+
+ public function read(int $length = 4096): StringHelper
+ {
+ return new StringHelper(array_shift($this->chunks));
+ }
+
+ public function send(string $data): void
+ {
+ }
+ };
+ }
+
+ protected function init(string $ftkey): void
+ {
+ }
+ };
+ $chunks = [];
+
+ $transfer->downloadTo('1234567890123456', 5, static function (StringHelper $data) use (&$chunks): void {
+ $chunks[] = $data->toString();
+ });
+
+ $this->assertSame(['ab', 'cde'], $chunks);
+ }
+
+ public function testDownloadCollectsPartialReadsWithoutSkippingData(): void
+ {
+ $transfer = new class (['host' => 'test', 'port' => 12345]) extends FileTransfer {
+ public function syn(): void
+ {
+ $this->transport = new class (['host' => 'test', 'port' => 12345]) extends Transport {
+ private array $chunks = ['ab', 'cde'];
+ public function connect(): void
+ {
+ }
+ public function disconnect(): void
+ {
+ }
+ public function read(int $length = 4096): StringHelper
+ {
+ return new StringHelper(array_shift($this->chunks));
+ }
+ public function send(string $data): void
+ {
+ }
+ };
+ }
+ protected function init(string $ftkey): void
+ {
+ }
+ };
+
+ $this->assertSame('abcde', $transfer->download('1234567890123456', 5)->toString());
+ }
+}
diff --git a/tests/Adapter/ServerQuery/ReplyTest.php b/tests/Adapter/ServerQuery/ReplyTest.php
index 4188c4c..92f0057 100644
--- a/tests/Adapter/ServerQuery/ReplyTest.php
+++ b/tests/Adapter/ServerQuery/ReplyTest.php
@@ -75,6 +75,17 @@ public function testToString()
$this->assertEquals(static::$E_SERVERLIST, (string) $reply->toString());
}
+ public function testToStringDoesNotChangeSubsequentParsing(): void
+ {
+ $reply = new Reply([
+ new StringHelper('virtualserver_name=TeamSpeak\\sServer'),
+ new StringHelper(static::$S_ERROR_OK),
+ ]);
+
+ $this->assertSame('virtualserver_name=TeamSpeak Server', $reply->toString()->toString());
+ $this->assertSame('TeamSpeak Server', $reply->toArray()[0]['virtualserver_name']->toString());
+ }
+
public function testToLines()
{
$reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]);
diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php
index a45b257..d13cb38 100644
--- a/tests/Helper/CharTest.php
+++ b/tests/Helper/CharTest.php
@@ -258,6 +258,13 @@ public function testUnicode1Byte()
);
}
+ public function testFromHexRejectsMalformedInput(): void
+ {
+ $this->expectException(HelperException::class);
+
+ Char::fromHex('zz');
+ }
+
/**
* @throws HelperException
*/
diff --git a/tests/Helper/ProfilerTest.php b/tests/Helper/ProfilerTest.php
index 8f45cec..c2cc9cd 100644
--- a/tests/Helper/ProfilerTest.php
+++ b/tests/Helper/ProfilerTest.php
@@ -38,4 +38,13 @@ public function testProfilerCreatesStartsStopsAndReturnsNamedTimers(): void
Profiler::stop('created-on-stop');
$this->assertInstanceOf(Timer::class, Profiler::get('created-on-stop'));
}
+
+ public function testProfilerRemovesTimers(): void
+ {
+ Profiler::init('removed');
+ Profiler::remove('removed');
+
+ $property = new \ReflectionProperty(Profiler::class, 'timers');
+ $this->assertArrayNotHasKey('removed', $property->getValue());
+ }
}
diff --git a/tests/Helper/SignalTest.php b/tests/Helper/SignalTest.php
index d0ccb78..94d4f91 100644
--- a/tests/Helper/SignalTest.php
+++ b/tests/Helper/SignalTest.php
@@ -31,6 +31,17 @@ public function testGetInstance()
$this->assertEmpty(Signal::getInstance()->getSignals());
}
+ public function testClearAllHandlers(): void
+ {
+ $signal = Signal::getInstance();
+ $signal->subscribe(static::$signal, static::$callback);
+ $signal->subscribe('anotherSignal', static::$callback);
+
+ $signal->clearAllHandlers();
+
+ $this->assertSame([], $signal->getSignals());
+ }
+
/**
* @throws HelperException
*/
diff --git a/tests/Helper/StringTest.php b/tests/Helper/StringTest.php
index db5126a..cc235cf 100644
--- a/tests/Helper/StringTest.php
+++ b/tests/Helper/StringTest.php
@@ -110,6 +110,16 @@ public function testIsInt()
}
}
+ public function testToIntSupportsValuesAboveSignedThirtyTwoBitRange(): void
+ {
+ if (PHP_INT_SIZE < 8) {
+ $this->markTestSkipped('Requires a 64-bit PHP integer.');
+ }
+
+ $this->assertSame(2147483648, (new StringHelper('2147483648'))->toInt());
+ $this->assertSame(-1, (new StringHelper('9223372036854775808'))->toInt());
+ }
+
public function testFactory()
{
$string = StringHelper::factory("hello world");
@@ -232,9 +242,14 @@ function ($mb_string, $item) {
return $mb_string;
}
));
- $this->assertTrue($upperUtf8MultibyteChar->isUtf8());
+ $this->assertSame(
+ $boundary[1][0] !== 0xF0,
+ $upperUtf8MultibyteChar->isUtf8()
+ );
}
+ $this->assertFalse((new StringHelper("\xC2\x80\xFF"))->isUtf8());
+
foreach ($unicodeBoundariesMalformed as $boundary) {
$lowerUtf8MultibyteChar = new StringHelper(array_reduce(
$boundary[0],
@@ -296,6 +311,13 @@ public function testFromBase64()
$this->assertEquals("Hello world!", $string->toString());
}
+ public function testFromBase64RejectsMalformedInput(): void
+ {
+ $this->expectException(HelperException::class);
+
+ StringHelper::fromBase64('not valid base64!');
+ }
+
/**
* @throws Exception
*/
@@ -315,6 +337,13 @@ public function testFromHex()
$this->assertEquals("Hello", $string->toString());
}
+ public function testFromHexRejectsMalformedInput(): void
+ {
+ $this->expectException(HelperException::class);
+
+ StringHelper::fromHex('zz');
+ }
+
public function testTransliterate()
{
$utf8_accents = [
diff --git a/tests/Helper/UriTest.php b/tests/Helper/UriTest.php
index a06c115..366b59f 100644
--- a/tests/Helper/UriTest.php
+++ b/tests/Helper/UriTest.php
@@ -19,6 +19,8 @@ class UriTest extends TestCase
'options' => [
'timeout',
'blocking',
+ 'tls',
+ 'tls_verify',
'nickname',
'no_query_clients',
'use_offline_as_virtual',
@@ -81,6 +83,29 @@ public function testConstructEmptyURI()
new Uri('');
}
+ public function testParameterHelpersPreserveFalsyValues(): void
+ {
+ $_REQUEST['uri_test_zero'] = '0';
+ $_SERVER['uri_test_false'] = false;
+ $_SESSION['uri_test_empty'] = [];
+
+ try {
+ $this->assertSame('0', Uri::getUserParam('uri_test_zero', 'default'));
+ $this->assertFalse(Uri::getHostParam('uri_test_false', true));
+ $this->assertSame([], Uri::getSessParam('uri_test_empty', ['default']));
+ } finally {
+ unset($_REQUEST['uri_test_zero'], $_SERVER['uri_test_false'], $_SESSION['uri_test_empty']);
+ }
+ }
+
+ public function testEncodedQueryVariableNamesAreHandledConsistently(): void
+ {
+ $uri = new Uri('serverquery://127.0.0.1:10011/?tls%5Fverify=1');
+
+ $this->assertTrue($uri->hasQueryVar('tls_verify'));
+ $this->assertSame(1, $uri->getQueryVar('tls_verify'));
+ }
+
public function testConstructInvalidScheme()
{
$this->expectException(HelperException::class);
diff --git a/tests/Node/ChannelTest.php b/tests/Node/ChannelTest.php
new file mode 100644
index 0000000..dfbbfaf
--- /dev/null
+++ b/tests/Node/ChannelTest.php
@@ -0,0 +1,32 @@
+createMock(Server::class);
+ $channel = new Channel($server, ['cid' => 1]);
+ $client = new Client($server, ['clid' => 2, 'cid' => 1]);
+
+ $server->method('clientList')->willReturn([2 => $client]);
+
+ $this->assertSame($client, $channel->clientGetById(2));
+ }
+
+ public function testSubChannelGetByIdReturnsChildFromChannelList(): void
+ {
+ $server = $this->createMock(Server::class);
+ $parent = new Channel($server, ['cid' => 1]);
+ $child = new Channel($server, ['cid' => 2, 'pid' => 1]);
+ $server->method('channelList')->willReturn([2 => $child]);
+
+ $this->assertSame($child, $parent->subChannelGetById(2));
+ }
+}
diff --git a/tests/Node/NodeTest.php b/tests/Node/NodeTest.php
index d79d1bc..c0cfc13 100644
--- a/tests/Node/NodeTest.php
+++ b/tests/Node/NodeTest.php
@@ -60,6 +60,13 @@ public function testRendersItsTreeWithTextViewer(): void
$this->assertSame("* root\n\\-* child\n", $node->getViewer(new Text()));
}
+
+ public function testFilterExcludesNodesWithoutTheRequestedProperty(): void
+ {
+ $node = new TestNode(1, ['name' => 'root']);
+
+ $this->assertSame([], $node->filterForTest([$node], ['missing' => 'value']));
+ }
}
class TestNode extends Node
@@ -90,4 +97,9 @@ public function __toString(): string
{
return $this->nodeInfo['name'] ?? 'unnamed';
}
+
+ public function filterForTest(array $nodes, array $rules): array
+ {
+ return $this->filterList($nodes, $rules);
+ }
}
diff --git a/tests/TeamSpeak3Test.php b/tests/TeamSpeak3Test.php
index c654da8..40dca05 100644
--- a/tests/TeamSpeak3Test.php
+++ b/tests/TeamSpeak3Test.php
@@ -27,4 +27,12 @@ public function testEscapePatternsAndDumpAreAvailable(): void
$this->assertStringContainsString("array(1)", $dump);
$this->assertStringContainsString("", $dump);
}
+
+ public function testTransferClientIdIsWithinProtocolRange(): void
+ {
+ $id = TeamSpeak3::generateTransferClientId();
+
+ $this->assertGreaterThanOrEqual(0, $id);
+ $this->assertLessThanOrEqual(0xFFFF, $id);
+ }
}
diff --git a/tests/Transport/TCPTest.php b/tests/Transport/TCPTest.php
index cac9dcb..c931091 100644
--- a/tests/Transport/TCPTest.php
+++ b/tests/Transport/TCPTest.php
@@ -73,6 +73,9 @@ public function testConstructorNoException()
$this->assertArrayHasKey('timeout', $adapter->getConfig());
$this->assertIsInt($adapter->getConfig('timeout'));
+ $this->assertArrayHasKey('tls_verify', $adapter->getConfig());
+ $this->assertSame(0, $adapter->getConfig('tls_verify'));
+
$this->assertArrayHasKey('blocking', $adapter->getConfig());
$this->assertIsInt($adapter->getConfig('blocking'));
}
@@ -103,10 +106,12 @@ public function testGetConfig()
);
$this->assertIsArray($adapter->getConfig());
- $this->assertCount(4, $adapter->getConfig());
+ $this->assertCount(5, $adapter->getConfig());
$this->assertArrayHasKey('host', $adapter->getConfig());
$this->assertEquals('test', $adapter->getConfig()['host']);
$this->assertEquals('test', $adapter->getConfig('host'));
+ $this->assertNull($adapter->getConfig('missing'));
+ $this->assertFalse($adapter->getConfig('missing', false));
}
/**
@@ -217,6 +222,38 @@ public function testDisconnectNoConnection()
$transport->disconnect();
}
+ public function testDisconnectClosesRetainedStream(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP {
+ public function setStreamForTest($stream): void
+ {
+ $this->stream = $stream;
+ }
+ };
+ $stream = fopen('php://temp', 'r+');
+ $transport->setStreamForTest($stream);
+
+ $transport->disconnect();
+
+ $this->assertFalse(is_resource($stream));
+ $this->assertNull($transport->getStream());
+ }
+
+ public function testReadThrowsWhenStreamReachedEndOfFile(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP {
+ public function setStreamForTest($stream): void
+ {
+ $this->stream = $stream;
+ }
+ };
+ $transport->setStreamForTest(fopen('php://temp', 'r'));
+
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage("connection to server 'test:12345' lost");
+ $transport->read();
+ }
+
/**
* @throws ServerQueryException
* @throws TransportException
@@ -274,6 +311,25 @@ public function testSendNoConnection()
$transport->send('testsend');
}
+ public function testSendRetriesPartialWritesAndRejectsFailedWrites(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP {
+ public array $writes = [];
+ private array $results = [2, 3];
+ public function connect(): void
+ {
+ $this->stream = true;
+ }
+ protected function write(string $data): int|false
+ {
+ $this->writes[] = $data;
+ return array_shift($this->results);
+ }
+ };
+ $transport->send('hello');
+ $this->assertSame(['hello', 'llo'], $transport->writes);
+ }
+
/**
* @throws ServerQueryException
* @throws TransportException
@@ -292,4 +348,93 @@ public function testSendLineNoConnection()
}
$transport->sendLine('test.sendLine');
}
+
+ public function testNonBlockingReadTimesOut(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345, 'blocking' => 0, 'timeout' => 0]) extends TCP {
+ private $peer;
+
+ public function connectForTest(): void
+ {
+ [$this->stream, $this->peer] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
+ }
+
+ public function waitForReadForTest(): void
+ {
+ $this->waitForReadyRead();
+ }
+ };
+
+ $transport->connectForTest();
+
+ $this->expectException(TransportException::class);
+ $this->expectExceptionMessage("timed out waiting for data from server 'test:12345'");
+ $transport->waitForReadForTest();
+ }
+
+ public function testTlsVerificationIsDisabledByDefault(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1]) extends TCP {
+ public array $contextOptions;
+
+ protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed
+ {
+ $this->contextOptions = $options;
+ return fopen('php://temp', 'r+');
+ }
+
+ protected function enableCrypto(): bool
+ {
+ return true;
+ }
+ };
+
+ $transport->connect();
+
+ $this->assertSame(['allow_self_signed' => true, 'verify_peer' => false, 'verify_peer_name' => false], $transport->contextOptions['ssl']);
+ }
+
+ public function testTlsVerificationCanBeEnabled(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1, 'tls_verify' => 1]) extends TCP {
+ public array $contextOptions;
+
+ protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed
+ {
+ $this->contextOptions = $options;
+ return fopen('php://temp', 'r+');
+ }
+
+ protected function enableCrypto(): bool
+ {
+ return true;
+ }
+ };
+
+ $transport->connect();
+
+ $this->assertSame(['allow_self_signed' => false, 'verify_peer' => true, 'verify_peer_name' => true], $transport->contextOptions['ssl']);
+ }
+
+ public function testFailedTlsNegotiationDisconnectsTransport(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1]) extends TCP {
+ protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed
+ {
+ return fopen('php://temp', 'r+');
+ }
+
+ protected function enableCrypto(): bool
+ {
+ return false;
+ }
+ };
+
+ try {
+ $transport->connect();
+ $this->fail('Expected TLS negotiation to fail.');
+ } catch (TransportException) {
+ $this->assertNull($transport->getStream());
+ }
+ }
}
diff --git a/tests/Transport/UDPTest.php b/tests/Transport/UDPTest.php
index 6d4300c..8a59197 100644
--- a/tests/Transport/UDPTest.php
+++ b/tests/Transport/UDPTest.php
@@ -38,6 +38,9 @@ public function testConstructorNoException()
$this->assertArrayHasKey('timeout', $adapter->getConfig());
$this->assertIsInt($adapter->getConfig('timeout'));
+ $this->assertArrayHasKey('tls_verify', $adapter->getConfig());
+ $this->assertSame(0, $adapter->getConfig('tls_verify'));
+
$this->assertArrayHasKey('blocking', $adapter->getConfig());
$this->assertIsInt($adapter->getConfig('blocking'));
}
@@ -68,7 +71,7 @@ public function testGetConfig()
);
$this->assertIsArray($adapter->getConfig());
- $this->assertCount(4, $adapter->getConfig());
+ $this->assertCount(5, $adapter->getConfig());
$this->assertArrayHasKey('host', $adapter->getConfig());
$this->assertEquals('test', $adapter->getConfig()['host']);
$this->assertEquals('test', $adapter->getConfig('host'));
@@ -152,6 +155,23 @@ public function testDisconnectNoConnection()
$transport->disconnect();
}
+ public function testDisconnectClosesRetainedStream(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345]) extends UDP {
+ public function setStreamForTest($stream): void
+ {
+ $this->stream = $stream;
+ }
+ };
+ $stream = fopen('php://temp', 'r+');
+ $transport->setStreamForTest($stream);
+
+ $transport->disconnect();
+
+ $this->assertFalse(is_resource($stream));
+ $this->assertNull($transport->getStream());
+ }
+
/**
* @throws TransportException
*/
@@ -187,4 +207,20 @@ public function testSendNoConnection()
}
$transport->send('test.send');
}
+
+ public function testSendRejectsPartialDatagrams(): void
+ {
+ $transport = new class (['host' => 'test', 'port' => 12345]) extends UDP {
+ public function connect(): void
+ {
+ $this->stream = true;
+ }
+ protected function sendTo(string $data): int|false
+ {
+ return 1;
+ }
+ };
+ $this->expectException(TransportException::class);
+ $transport->send('ab');
+ }
}
diff --git a/tests/Viewer/ViewerTest.php b/tests/Viewer/ViewerTest.php
index 6de241e..5427e00 100644
--- a/tests/Viewer/ViewerTest.php
+++ b/tests/Viewer/ViewerTest.php
@@ -47,6 +47,17 @@ public function testHtmlViewerRendersEscapedServerInformationAndIcons(): void
$this->assertStringContainsString('Clients: 3/32', $html);
}
+ public function testHtmlImageAttributesAreEscaped(): void
+ {
+ $viewer = new HtmlForTest("icons/'onerror='alert(1)");
+
+ $html = $viewer->getImageForTest("image.png' onerror='alert(2)", "title' onmouseover='alert(3)");
+
+ $this->assertStringNotContainsString("' onerror=", $html);
+ $this->assertStringNotContainsString("' onmouseover=", $html);
+ $this->assertStringContainsString(''', $html);
+ }
+
public function testJsonViewerProducesStructuredServerData(): void
{
$data = [];
@@ -127,3 +138,11 @@ public function count(): int
return 0;
}
}
+
+class HtmlForTest extends Html
+{
+ public function getImageForTest(string $name, string $text): string
+ {
+ return $this->getImage($name, $text);
+ }
+}