diff --git a/phpunit.xml b/phpunit.xml
index 1c4a840..644e542 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -16,4 +16,11 @@
./tests/
+
+
+
+
+
+
+
diff --git a/src/APIConnector.php b/src/APIConnector.php
new file mode 100644
index 0000000..f85c013
--- /dev/null
+++ b/src/APIConnector.php
@@ -0,0 +1,59 @@
+stream = $stream;
+ }
+
+ /**
+ * Reads a WORD from the stream
+ *
+ * WORDs are part of SENTENCE. Each WORD has to be encoded in certain way - length of the WORD followed by WORD content.
+ * Length of the WORD should be given as count of bytes that are going to be sent
+ *
+ * @return string The word content, en empty string for end of SENTENCE
+ */
+ public function readWord(): string
+ {
+ // Get length of next word
+ $length = APILengthCoDec::decodeLength($this->stream);
+ return ($length > 0) ? $this->stream->read($length) : '';
+ }
+
+ /**
+ * Write word to stream
+ *
+ * @param string $word
+ * @return int return number of written bytes
+ */
+ public function writeWord(string $word): int
+ {
+ $encodedLength = APILengthCoDec::encodeLength(strlen($word));
+ return $this->stream->write($encodedLength . $word);
+ }
+}
diff --git a/src/APILengthCoDec.php b/src/APILengthCoDec.php
new file mode 100644
index 0000000..2f275bd
--- /dev/null
+++ b/src/APILengthCoDec.php
@@ -0,0 +1,196 @@
+ 7 bits set to 1)
+ // - encode length with one byte
+ // - set the byte to length value, as length maximal value is 7 bits set to 1, the most significant bit is always 0
+ // - end
+ // - length <= 0x3FFF (binary : 00111111 11111111 => 14 bits set to 1)
+ // - encode length with two bytes
+ // - set length value to 0x8000 (=> 10000000 00000000)
+ // - add length : as length maximumal value is 14 bits to 1, this does not modify the 2 most significance bits (10)
+ // - end
+ // => minimal encoded value is 10000000 10000000
+ // - length <= 0x1FFFFF (binary : 00011111 11111111 11111111 => 21 bits set to 1)
+ // - encode length with three bytes
+ // - set length value to 0xC00000 (binary : 11000000 00000000 00000000)
+ // - add length : as length maximal vlaue is 21 bits to 1, this does not modify the 3 most significance bits (110)
+ // - end
+ // => minimal encoded value is 11000000 01000000 00000000
+ // - length <= 0x0FFFFFFF (binary : 00001111 11111111 11111111 11111111 => 28 bits set to 1)
+ // - encode length with four bytes
+ // - set length value to 0xE0000000 (binary : 11100000 00000000 00000000 00000000)
+ // - add length : as length maximal vlaue is 28 bits to 1, this does not modify the 4 most significance bits (1110)
+ // - end
+ // => minimal encoded value is 11100000 00100000 00000000 00000000
+ // - length <= 0x7FFFFFFFFF (binary : 00000111 11111111 11111111 11111111 11111111 => 35 bits set to 1)
+ // - encode length with five bytes
+ // - set length value to 0xF000000000 (binary : 11110000 00000000 00000000 00000000 00000000)
+ // - add length : as length maximal vlaue is 35 bits to 1, this does not modify the 5 most significance bits (11110)
+ // - end
+ // - length > 0x7FFFFFFFFF : not supported
+
+ if ($length < 0) {
+ throw new \DomainException("Length of word could not to be negative ($length)");
+ }
+
+ if ($length <= 0x7F) {
+ return BinaryStringHelper::IntegerToNBOBinaryString($length);
+ }
+
+ if ($length <= 0x3FFF) {
+ return BinaryStringHelper::IntegerToNBOBinaryString(0x8000 + $length);
+ }
+
+ if ($length <= 0x1FFFFF) {
+ return BinaryStringHelper::IntegerToNBOBinaryString(0xC00000 + $length);
+ }
+
+ if ($length <= 0x0FFFFFFF) {
+ return BinaryStringHelper::IntegerToNBOBinaryString(0xE0000000 + $length);
+ }
+
+ // https://wiki.mikrotik.com/wiki/Manual:API#API_words
+ // If len >= 0x10000000 then 0xF0 and len as four bytes
+ return BinaryStringHelper::IntegerToNBOBinaryString(0xF000000000 + $length);
+ }
+
+ // Decode length of data when reading :
+ // The 5 firsts bits of the first byte specify how the length is encoded.
+ // The position of the first 0 value bit, starting from the most significant postion.
+ // - 0xxxxxxx => The 7 remainings bits of the first byte is the length :
+ // => min value of length is 0x00
+ // => max value of length is 0x7F (127 bytes)
+ // - 10xxxxxx => The 6 remainings bits of the first byte plus the next byte represent the lenght
+ // NOTE : the next byte MUST be at least 0x80 !!
+ // => min value of length is 0x80
+ // => max value of length is 0x3FFF (16,383 bytes, near 16 KB)
+ // - 110xxxxx => The 5 remainings bits of th first byte and the two next bytes represent the length
+ // => max value of length is 0x1FFFFF (2,097,151 bytes, near 2 MB)
+ // - 1110xxxx => The 4 remainings bits of the first byte and the three next bytes represent the length
+ // => max value of length is 0xFFFFFFF (268,435,455 bytes, near 270 MB)
+ // - 11110xxx => The 3 remainings bits of the first byte and the four next bytes represent the length
+ // => max value of length is 0x7FFFFFFF (2,147,483,647 byes, 2GB)
+ // - 11111xxx => This byte is not a length-encoded word but a control byte.
+ // => Extracted from Mikrotik API doc :
+ // it is a reserved control byte.
+ // After receiving unknown control byte API client cannot proceed, because it cannot know how to interpret following bytes
+ // Currently control bytes are not used
+
+ public static function decodeLength(StreamInterface $stream): int
+ {
+ // if (false === is_resource($stream)) {
+ // throw new \InvalidArgumentException(
+ // sprintf(
+ // 'Argument must be a stream resource type. %s given.',
+ // gettype($stream)
+ // )
+ // );
+ // }
+
+ // Read first byte
+ $firstByte = ord($stream->read(1));
+
+ // If first byte is not set, length is the value of the byte
+ if (0 === ($firstByte & 0x80)) {
+ return $firstByte;
+ }
+
+ // if 10xxxxxx, length is 2 bytes encoded
+ if (0x80 === ($firstByte & 0xC0)) {
+ // Set 2 most significands bits to 0
+ $result = $firstByte & 0x3F;
+
+ // shift left 8 bits to have 2 bytes
+ $result <<= 8;
+
+ // read next byte and use it as least significant
+ $result |= ord($stream->read(1));
+ return $result;
+ }
+
+ // if 110xxxxx, length is 3 bytes encoded
+ if (0xC0 === ($firstByte & 0xE0)) {
+ // Set 3 most significands bits to 0
+ $result = $firstByte & 0x1F;
+
+ // shift left 16 bits to have 3 bytes
+ $result <<= 16;
+
+ // read next 2 bytes as value and use it as least significant position
+ $result |= (ord($stream->read(1)) << 8);
+ $result |= ord($stream->read(1));
+ return $result;
+ }
+
+ // if 1110xxxx, length is 4 bytes encoded
+ if (0xE0 === ($firstByte & 0xF0)) {
+ // Set 4 most significance bits to 0
+ $result = $firstByte & 0x0F;
+
+ // shift left 24 bits to have 4 bytes
+ $result <<= 24;
+
+ // read next 3 bytes as value and use it as least significant position
+ $result |= (ord($stream->read(1)) << 16);
+ $result |= (ord($stream->read(1)) << 8);
+ $result |= ord($stream->read(1));
+ return $result;
+ }
+
+ // if 11110xxx, length is 5 bytes encoded
+ if (0xF0 === ($firstByte & 0xF8)) {
+ // Not possible on 32 bits systems
+ if (PHP_INT_SIZE < 8) {
+ // Cannot be done on 32 bits systems
+ // PHP5 windows versions of php, even on 64 bits systems was impacted
+ // see : https://stackoverflow.com/questions/27865340/php-int-size-returns-4-but-my-operating-system-is-64-bit
+ // How can we test it ?
+
+ // @codeCoverageIgnoreStart
+ throw new \OverflowException("Your system is using 32 bits integers, cannot decode this value ($firstByte) on this system");
+ // @codeCoverageIgnoreEnd
+ }
+
+ // Set 5 most significance bits to 0
+ $result = $firstByte & 0x07;
+
+ // shift left 232 bits to have 5 bytes
+ $result <<= 32;
+
+ // read next 4 bytes as value and use it as least significant position
+ $result |= (ord($stream->read(1)) << 24);
+ $result |= (ord($stream->read(1)) << 16);
+ $result |= (ord($stream->read(1)) << 8);
+ $result |= ord($stream->read(1));
+ return $result;
+ }
+
+ // Now the only solution is 5 most significance bits are set to 1 (11111xxx)
+ // This is a control word, not implemented by Mikrotik for the moment
+ throw new \UnexpectedValueException('Control Word found');
+ }
+}
diff --git a/src/Client.php b/src/Client.php
index bf3925d..d43cf16 100644
--- a/src/Client.php
+++ b/src/Client.php
@@ -18,32 +18,19 @@ class Client implements Interfaces\ClientInterface
use SocketTrait;
/**
- * Socket resource
+ * Configuration of connection
*
- * @var resource|null
+ * @var \RouterOS\Config
*/
- private $_socket;
+ private $_config;
/**
- * Code of error
+ * API communication object
*
- * @var int
+ * @var \RouterOS\APIConnector
*/
- private $_socket_err_num;
- /**
- * Description of socket error
- *
- * @var string
- */
- private $_socket_err_str;
-
- /**
- * Configuration of connection
- *
- * @var \RouterOS\Config
- */
- private $_config;
+ private $_connector;
/**
* Client constructor.
@@ -109,91 +96,10 @@ class Client implements Interfaces\ClientInterface
}
/**
- * Encode given length in RouterOS format
- *
- * @param string $string
- * @return string Encoded length
- * @throws \RouterOS\Exceptions\ClientException
- */
- private function encodeLength(string $string): string
- {
- $length = \strlen($string);
-
- if ($length < 128) {
- $orig_length = $length;
- $offset = -1;
- } elseif ($length < 16384) {
- $orig_length = $length | 0x8000;
- $offset = -2;
- } elseif ($length < 2097152) {
- $orig_length = $length | 0xC00000;
- $offset = -3;
- } elseif ($length < 268435456) {
- $orig_length = $length | 0xE0000000;
- $offset = -4;
- } else {
- throw new ClientException("Unable to encode length of '$string'");
- }
-
- // Pack string to binary format
- $result = pack('I*', $orig_length);
- // Parse binary string to array
- $result = str_split($result);
- // Reverse array
- $result = array_reverse($result);
- // Extract values from offset to end of array
- $result = \array_slice($result, $offset);
-
- // Sew items into one line
- $output = null;
- foreach ($result as $item) {
- $output .= $item;
- }
-
- return $output;
- }
-
- /**
- * Read length of line
- *
- * @param int $byte
- * @return int
- */
- private function getLength(int $byte): int
- {
- // If the first bit is set then we need to remove the first four bits, shift left 8
- // and then read another byte in.
- // We repeat this for the second and third bits.
- // If the fourth bit is set, we need to remove anything left in the first byte
- // and then read in yet another byte.
- if ($byte & 128) {
- if (($byte & 192) === 128) {
- $length = (($byte & 63) << 8) + \ord(fread($this->_socket, 1));
- } elseif (($byte & 224) === 192) {
- $length = (($byte & 31) << 8) + \ord(fread($this->_socket, 1));
- $length = ($length << 8) + \ord(fread($this->_socket, 1));
- } elseif (($byte & 240) === 224) {
- $length = (($byte & 15) << 8) + \ord(fread($this->_socket, 1));
- $length = ($length << 8) + \ord(fread($this->_socket, 1));
- $length = ($length << 8) + \ord(fread($this->_socket, 1));
- } else {
- $length = \ord(fread($this->_socket, 1));
- $length = ($length << 8) + \ord(fread($this->_socket, 1)) * 3;
- $length = ($length << 8) + \ord(fread($this->_socket, 1));
- $length = ($length << 8) + \ord(fread($this->_socket, 1));
- }
- } else {
- $length = $byte;
- }
- return $length;
- }
-
- /**
* Send write query to RouterOS (with or without tag)
*
* @param string|array|\RouterOS\Query $query
* @return \RouterOS\Client
- * @throws \RouterOS\Exceptions\ClientException
* @throws \RouterOS\Exceptions\QueryException
*/
public function write($query): Client
@@ -211,12 +117,11 @@ class Client implements Interfaces\ClientInterface
// Send commands via loop to router
foreach ($query->getQuery() as $command) {
- $command = trim($command);
- fwrite($this->_socket, $this->encodeLength($command) . $command);
+ $this->_connector->writeWord(trim($command));
}
- // Write zero-terminator
- fwrite($this->_socket, \chr(0));
+ // Write zero-terminator (empty string)
+ $this->_connector->writeWord('');
return $this;
}
@@ -229,7 +134,7 @@ class Client implements Interfaces\ClientInterface
* Each block end with an zero byte (empty line)
* Reply ends with a complete !done or !fatal block (ended with 'empty line')
* A !fatal block precedes TCP connexion close
- *
+ *
* @param bool $parse
* @return array
*/
@@ -242,12 +147,9 @@ class Client implements Interfaces\ClientInterface
// Read answer from socket in loop
while (true) {
- // Read the first byte of input which gives us some or all of the length
- // of the remaining reply.
- $byte = fread($this->_socket, 1);
- $length = $this->getLength(\ord($byte));
+ $word = $this->_connector->readWord();
- if ($length == 0) {
+ if ('' === $word) {
if ($lastReply) {
// We received a !done or !fatal message in a precedent loop
// response is complete
@@ -260,12 +162,12 @@ class Client implements Interfaces\ClientInterface
}
// Save output line to response array
- $response[] = $line = stream_get_contents($this->_socket, $length);
+ $response[] = $word;
// If we get a !done or !fatal line in response, we are now ready to finish the read
// but we need to wait a 0 length message, switch the flag
- if ('!done' === $line || '!fatal' === $line) {
- $lastReply = true;
+ if ('!done' === $word || '!fatal' === $word) {
+ $lastReply = true;
}
}
@@ -278,7 +180,6 @@ class Client implements Interfaces\ClientInterface
*
* @param string|array|\RouterOS\Query $query
* @return \RouterOS\Client
- * @throws \RouterOS\Exceptions\ClientException
* @throws \RouterOS\Exceptions\QueryException
*/
public function w($query): Client
@@ -449,7 +350,7 @@ class Client implements Interfaces\ClientInterface
// If socket is active
if (null !== $this->getSocket()) {
-
+ $this->_connector = new APIConnector(new Streams\ResourceStream($this->getSocket()));
// If we logged in then exit from loop
if (true === $this->login()) {
$connected = true;
diff --git a/src/Exceptions/StreamException.php b/src/Exceptions/StreamException.php
new file mode 100644
index 0000000..c7dca3e
--- /dev/null
+++ b/src/Exceptions/StreamException.php
@@ -0,0 +1,14 @@
+ chr(0x0F).chr(0xF7)
+ * 0x12345678 => chr(0x12).chr(0x34).chr(0x56).chr(0x76)
+ * Compatible with 8, 16, 32, 64 etc.. bits systems
+ *
+ * @see https://en.wikipedia.org/wiki/Endianness
+ * @param int|float $value the integer value to be converted
+ * @return string the binary string
+ */
+ public static function IntegerToNBOBinaryString($value): string
+ {
+ // Initialize an empty string
+ $buffer = '';
+
+ // Lets start from the most significant byte
+ for ($i = (PHP_INT_SIZE - 1); $i >= 0; $i--) {
+ // Prepare a mask to keep only the most significant byte of $value
+ $mask = 0xFF << ($i * 8);
+
+ // If the most significant byte is not 0, the final string must contain it
+ // If we have already started to construct the string (i.e. there are more signficant digits)
+ // we must set the byte, even if it is a 0.
+ // 0xFF00FF, for example, require to set the second byte byte with a 0 value
+ if (($value & $mask) || $buffer !== '') {
+ // Get the current byte by shifting it to least significant position and add it to the string
+ // 0xFF12345678 => 0xFF
+ $byte = $value >> (8 * $i);
+ $buffer .= chr($byte);
+
+ // Set the most significant byte to 0 so we can restart the process being shure
+ // that the value is left padded with 0
+ // 0xFF12345678 => 0x12345678
+ // -1 = 0xFFFFF.... (number of F depend of PHP_INT_SIZE )
+ $mask = -1 >> ((PHP_INT_SIZE - $i) * 8);
+ $value &= $mask;
+ }
+ }
+
+ // Special case, 0 will not fill the buffer, have to construct it manualy
+ if (0 === $value) {
+ $buffer = chr(0);
+ }
+
+ return $buffer;
+ }
+}
diff --git a/src/Interfaces/StreamInterface.php b/src/Interfaces/StreamInterface.php
new file mode 100644
index 0000000..4131992
--- /dev/null
+++ b/src/Interfaces/StreamInterface.php
@@ -0,0 +1,46 @@
+stream = $stream;
+ }
+
+ /**
+ * @param int $length
+ * @return string
+ * @throws \RouterOS\Exceptions\StreamException
+ * @throws \InvalidArgumentException
+ */
+ public function read(int $length): string
+ {
+ if ($length <= 0) {
+ throw new \InvalidArgumentException('Cannot read zero ot negative count of bytes from a stream');
+ }
+
+ // TODO: Ignore errors here, but why?
+ $result = @fread($this->stream, $length);
+
+ if (false === $result) {
+ throw new StreamException("Error reading $length bytes");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Writes a string to a stream
+ *
+ * Write $length bytes of string, if not mentioned, write all the string
+ * Must be binary safe (as fread).
+ * if $length is greater than string length, write all string and return number of writen bytes
+ * if $length os smaller than string length, remaining bytes are losts.
+ *
+ * @param string $string
+ * @param int|null $length the numer of bytes to read
+ * @return int the number of written bytes
+ * @throws \RouterOS\Exceptions\StreamException
+ */
+ public function write(string $string, int $length = null): int
+ {
+ if (null === $length) {
+ $length = strlen($string);
+ }
+
+ // TODO: Ignore errors here, but why?
+ $result = @fwrite($this->stream, $string, $length);
+
+ if (false === $result) {
+ throw new StreamException("Error writing $length bytes");
+ }
+
+ return $result;
+ }
+
+ /**
+ * Close stream connection
+ *
+ * @return void
+ * @throws \RouterOS\Exceptions\StreamException
+ */
+ public function close()
+ {
+ $hasBeenClosed = false;
+
+ if (null !== $this->stream) {
+ $hasBeenClosed = @fclose($this->stream);
+ $this->stream = null;
+ }
+
+ if (false === $hasBeenClosed) {
+ throw new StreamException('Error closing stream');
+ }
+ }
+}
diff --git a/src/Streams/StringStream.php b/src/Streams/StringStream.php
new file mode 100644
index 0000000..e845f32
--- /dev/null
+++ b/src/Streams/StringStream.php
@@ -0,0 +1,97 @@
+buffer = $string;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws \InvalidArgumentException when length parameter is invalid
+ * @throws StreamException when the stream have been tatly red and read methd is called again
+ */
+ public function read(int $length): string
+ {
+ $remaining = strlen($this->buffer);
+
+ if ($length < 0) {
+ throw new \InvalidArgumentException('Cannot read a negative count of bytes from a stream');
+ }
+
+ if (0 === $remaining) {
+ throw new StreamException('End of stream');
+ }
+
+ if ($length >= $remaining) {
+ // returns all
+ $result = $this->buffer;
+ // No more in the buffer
+ $this->buffer = '';
+ } else {
+ // acquire $length characters from the buffer
+ $result = substr($this->buffer, 0, $length);
+ // remove $length characters from the buffer
+ $this->buffer = substr_replace($this->buffer, '', 0, $length);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Fake write method, do nothing except return the "writen" length
+ *
+ * @param string $string The string to write
+ * @param int|null $length the number of characters to write
+ * @return int number of "writen" bytes
+ * @throws \InvalidArgumentException on invalid length
+ */
+ public function write(string $string, int $length = null): int
+ {
+ if (null === $length) {
+ $length = strlen($string);
+ }
+
+ if ($length < 0) {
+ throw new \InvalidArgumentException('Cannot write a negative count of bytes');
+ }
+
+ return min($length, strlen($string));
+ }
+
+ /**
+ * Close stream connection
+ *
+ * @return void
+ */
+ public function close()
+ {
+ $this->buffer = '';
+ }
+}
diff --git a/tests/APIConnectorTest.php b/tests/APIConnectorTest.php
new file mode 100644
index 0000000..b4784fe
--- /dev/null
+++ b/tests/APIConnectorTest.php
@@ -0,0 +1,98 @@
+assertInstanceOf(APIConnector::class, $apiStream);
+ if ($closeResource) {
+ $apiStream->close();
+ }
+ }
+
+ public function constructProvider(): array
+ {
+ return [
+ [new ResourceStream(fopen(__FILE__, 'rb')),], // Myself, sure I exists
+ [new ResourceStream(fsockopen('tcp://' . getenv('ROS_HOST'), getenv('ROS_PORT_MODERN'))),], // Socket
+ [new ResourceStream(STDIN), false], // Try it, but do not close STDIN please !!!
+ [new StringStream('Hello World !!!')], // Try it, but do not close STDIN please !!!
+ [new StringStream('')], // Try it, but do not close STDIN please !!!
+ // What else ?
+ ];
+ }
+
+ /**
+ * @covers ::readWord
+ * @dataProvider readWordProvider
+ *
+ * @param APIConnector $connector
+ * @param string $expected
+ */
+ public function test__readWord(APIConnector $connector, string $expected)
+ {
+ $this->assertSame($expected, $connector->readWord());
+ }
+
+ public function readWordProvider(): array
+ {
+ $longString = '=comment=' . str_repeat('a', 10000);
+ $length = strlen($longString);
+
+ return [
+ [new APIConnector(new StringStream(chr(0))), ''],
+ [new APIConnector(new StringStream(chr(3) . '!re')), '!re'],
+ [new APIConnector(new StringStream(chr(5) . '!done')), '!done'],
+ [new APIConnector(new StringStream(APILengthCoDec::encodeLength($length) . $longString)), $longString],
+ ];
+ }
+
+ /**
+ * @covers ::writeWord
+ * @dataProvider writeWordProvider
+ *
+ * @param APIConnector $connector
+ * @param string $toWrite
+ * @param int $expected
+ */
+ public function test_writeWord(APIConnector $connector, string $toWrite, int $expected)
+ {
+ $this->assertEquals($expected, $connector->writeWord($toWrite));
+ }
+
+ public function writeWordProvider(): array
+ {
+ return [
+ [new APIConnector(new StringStream('Have FUN !!!')), '', 1], // length is 0, but have to write it on 1 byte, minimum
+ [new APIConnector(new StringStream('Have FUN !!!')), str_repeat(' ', 54), 55], // arbitrary value
+ [new APIConnector(new StringStream('Have FUN !!!')), str_repeat(' ', 127), 128], // maximum value for 1 byte encoding lentgth
+ [new APIConnector(new StringStream('Have FUN !!!')), str_repeat(' ', 128), 130], // minimum value for 2 bytes encoding lentgth
+ [new APIConnector(new StringStream('Have FUN !!!')), str_repeat(' ', 254), 256], // special value isn't it ?
+ [new APIConnector(new StringStream('Have FUN !!!')), str_repeat(' ', 255), 257], // special value isn't it ?
+ ];
+ }
+}
diff --git a/tests/APILengthCoDecTest.php b/tests/APILengthCoDecTest.php
new file mode 100644
index 0000000..5b59514
--- /dev/null
+++ b/tests/APILengthCoDecTest.php
@@ -0,0 +1,106 @@
+assertEquals(BinaryStringHelper::IntegerToNBOBinaryString((int) $expected), APILengthCoDec::encodeLength($length));
+ }
+
+ public function encodedLengthProvider(): array
+ {
+ // [encoded length value, length value]
+ $result = [
+ [0, 0], // Low limit value for 1 byte encoded length
+ [0x39, 0x39], // Arbitrary median value for 1 byte encoded length
+ [0x7f, 0x7F], // High limit value for 1 byte encoded length
+
+ [0x8080, 0x80], // Low limit value for 2 bytes encoded length
+ [0x9C42, 0x1C42], // Arbitrary median value for 2 bytes encoded length
+ [0xBFFF, 0x3FFF], // High limit value for 2 bytes encoded length
+
+ [0xC04000, 0x4000], // Low limit value for 3 bytes
+ [0xCAD73B, 0xAD73B], // Arbitrary median value for 3 bytes encoded length
+ [0xDFFFFF, 0x1FFFFF], // High limit value for 3 bytes encoded length
+
+ [0xE0200000, 0x200000], // Low limit value for 4 bytes encoded length
+ [0xE5AD736B, 0x5AD736B], // Arbitrary median value for 4 bytes encoded length
+ [0xEFFFFFFF, 0xFFFFFFF], // High limit value for 4 bytes encoded length
+ ];
+
+ if (PHP_INT_SIZE > 4) {
+ $result[] = [0xF010000000, 0x10000000]; // Low limit value for 5 bytes encoded length
+ $result[] = [0xF10D4EF9C3, 0x10D4EF9C3]; // Arbitrary median value for 5 bytes encoded length
+ $result[] = [0xF7FFFFFFFF, 0x7FFFFFFFF]; // High limit value for 5 bytes encoded length
+ }
+
+ return $result;
+ }
+
+ /**
+ * @dataProvider encodedLengthProvider
+ * @covers ::decodeLength
+ */
+ public function test__decodeLength($encodedLength, $expected)
+ {
+ // We have to provide $encodedLength as a "bytes" stream
+ $stream = new StringStream(BinaryStringHelper::IntegerToNBOBinaryString($encodedLength));
+ $this->assertEquals($expected, APILengthCoDec::decodeLength($stream));
+ }
+
+ /**
+ * @dataProvider decodeLengthControlWordProvider
+ * @covers ::decodeLength
+ * @expectedException \UnexpectedValueException
+ */
+ public function test_decodeLengthControlWord(string $encodedLength)
+ {
+ APILengthCoDec::decodeLength(new StringStream($encodedLength));
+ }
+
+ public function decodeLengthControlWordProvider(): array
+ {
+ // Control bytes: 5 most significance its sets to 1
+ return [
+ [chr(0xF8)], // minimum
+ [chr(0xFC)], // arbitrary value
+ [chr(0xFF)], // maximum
+ ];
+ }
+}
diff --git a/tests/ClientTest.php b/tests/ClientTest.php
index a899a5d..3203b23 100644
--- a/tests/ClientTest.php
+++ b/tests/ClientTest.php
@@ -16,7 +16,7 @@ class ClientTest extends TestCase
{
try {
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
$this->assertInternalType('object', $obj);
$socket = $obj->getSocket();
@@ -30,9 +30,9 @@ class ClientTest extends TestCase
{
try {
$config = new Config([
- 'user' => 'admin',
- 'pass' => 'admin',
- 'host' => '127.0.0.1'
+ 'user' => getenv('ROS_USER'),
+ 'pass' => getenv('ROS_PASS'),
+ 'host' => getenv('ROS_HOST')
]);
$obj = new Client($config);
$this->assertInternalType('object', $obj);
@@ -47,9 +47,9 @@ class ClientTest extends TestCase
{
try {
$obj = new Client([
- 'user' => 'admin',
- 'pass' => 'admin',
- 'host' => '127.0.0.1'
+ 'user' => getenv('ROS_USER'),
+ 'pass' => getenv('ROS_PASS'),
+ 'host' => getenv('ROS_HOST')
]);
$this->assertInternalType('object', $obj);
$socket = $obj->getSocket();
@@ -64,8 +64,8 @@ class ClientTest extends TestCase
$this->expectException(ConfigException::class);
$obj = new Client([
- 'user' => 'admin',
- 'pass' => 'admin',
+ 'user' => getenv('ROS_USER'),
+ 'pass' => getenv('ROS_PASS'),
]);
}
@@ -73,8 +73,8 @@ class ClientTest extends TestCase
{
try {
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')
- ->set('host', '127.0.0.1')->set('port', 18728)->set('legacy', true);
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))
+ ->set('host', getenv('ROS_HOST'))->set('port', (int) getenv('ROS_PORT_MODERN'))->set('legacy', true);
$obj = new Client($config);
$this->assertInternalType('object', $obj);
} catch (\Exception $e) {
@@ -84,15 +84,15 @@ class ClientTest extends TestCase
/**
* Test non legacy connection on legacy router (pre 6.43)
- *
+ *
* login() method recognise legacy router response and swap to legacy mode
- */
+ */
public function test__constructLegacy2()
{
try {
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')
- ->set('host', '127.0.0.1')->set('port', 18728)->set('legacy', false);
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))
+ ->set('host', getenv('ROS_HOST'))->set('port', (int) getenv('ROS_PORT_MODERN'))->set('legacy', false);
$obj = new Client($config);
$this->assertInternalType('object', $obj);
} catch (\Exception $e) {
@@ -106,7 +106,7 @@ class ClientTest extends TestCase
$this->expectException(ClientException::class);
$config = (new Config())->set('attempts', 2);
- $config->set('user', 'admin')->set('pass', 'admin2')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', 'admin2')->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
}
@@ -118,14 +118,14 @@ class ClientTest extends TestCase
$this->expectException(ClientException::class);
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1')->set('port', 11111);
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'))->set('port', 11111);
$obj = new Client($config);
}
public function testWriteRead()
{
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
$query = new Query('/ip/address/print');
@@ -157,7 +157,7 @@ class ClientTest extends TestCase
public function testWriteReadString()
{
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
$readTrap = $obj->wr('/interface', false);
@@ -168,7 +168,7 @@ class ClientTest extends TestCase
public function testWriteReadArray()
{
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
$readTrap = $obj->wr(['/interface'], false);
@@ -179,7 +179,7 @@ class ClientTest extends TestCase
public function testFatal()
{
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
$readTrap = $obj->wr('/quit');
@@ -192,7 +192,7 @@ class ClientTest extends TestCase
$this->expectException(QueryException::class);
$config = new Config();
- $config->set('user', 'admin')->set('pass', 'admin')->set('host', '127.0.0.1');
+ $config->set('user', getenv('ROS_USER'))->set('pass', getenv('ROS_PASS'))->set('host', getenv('ROS_HOST'));
$obj = new Client($config);
$error = $obj->write($obj)->read(false);
}
@@ -200,9 +200,9 @@ class ClientTest extends TestCase
public function testGetConfig()
{
$obj = new Client([
- 'user' => 'admin',
- 'pass' => 'admin',
- 'host' => '127.0.0.1'
+ 'user' => getenv('ROS_USER'),
+ 'pass' => getenv('ROS_PASS'),
+ 'host' => getenv('ROS_HOST')
]);
$config = $obj->getConfig();
diff --git a/tests/Helpers/BinaryStringHelperTest.php b/tests/Helpers/BinaryStringHelperTest.php
new file mode 100644
index 0000000..014f17a
--- /dev/null
+++ b/tests/Helpers/BinaryStringHelperTest.php
@@ -0,0 +1,49 @@
+assertEquals($expected, BinaryStringHelper::IntegerToNBOBinaryString($value));
+ }
+
+ public function IntegerToNBOBinaryStringProvider(): array
+ {
+ $result = [
+ [0, chr(0)], // lower boundary value
+ [0xFFFFFFFF, chr(0xFF) . chr(0xFF) . chr(0xFF) . chr(0xFF)], // 32 bits maximal value
+
+ // strange behaviour :
+ // TypeError: Argument 1 passed to RouterOS\Tests\Helpers\BinaryStringHelperTest::test__IntegerToNBOBinaryString() must be of the type integer, float given
+ // Seems that php auto convert to float 0xFFF....
+ //
+ // [0xFFFFFFFFFFFFFFFF, chr(0xFF).chr(0xFF).chr(0xFF).chr(0xFF).chr(0xFF).chr(0xFF).chr(0xFF).chr(0xFF)],
+
+ // Let's try random value
+ [0x390DDD99, chr(0x39) . chr(0x0D) . chr(0xDD) . chr(0x99)],
+ ];
+
+ if (PHP_INT_SIZE > 4) {
+ // -1 is encoded with 0xFFFFFFF.....
+ // 64 bits maximal value (on a 64 bits system only)
+ $result[] = [-1, chr(0xFF) . chr(0xFF) . chr(0xFF) . chr(0xFF) . chr(0xFF) . chr(0xFF) . chr(0xFF) . chr(0xFF)]; // 64 bits upper boundary value
+ }
+
+ return $result;
+ }
+}
diff --git a/tests/Streams/ResourceStreamTest.php b/tests/Streams/ResourceStreamTest.php
new file mode 100644
index 0000000..fedb316
--- /dev/null
+++ b/tests/Streams/ResourceStreamTest.php
@@ -0,0 +1,263 @@
+getObjectAttribute($resourceStream, 'stream');
+ $this->assertInternalType(IsType::TYPE_RESOURCE, $stream);
+
+ if ($closeResource) {
+ fclose($resource);
+ }
+ }
+
+ /**
+ * Data provider for test__construct
+ *
+ * @return array data of type resource
+ */
+ public function constructProvider(): array
+ {
+ return [
+ [fopen(__FILE__, 'rb'),], // Myself, sure I exists
+ [fsockopen('tcp://' . getenv('ROS_HOST'), getenv('ROS_PORT_MODERN')),], // Socket
+ [STDIN, false], // Try it, but do not close STDIN please !!!
+ // What else ?
+ ];
+ }
+
+ /**
+ * Test that read function return expected values, and that consecutive reads return data
+ *
+ * @covers ::read
+ * @dataProvider readProvider
+ *
+ * @param ResourceStream $stream Cannot typehint, PHP refuse it
+ * @param string $expected the result we should have
+ * @throws \RouterOS\Exceptions\StreamException
+ * @throws \InvalidArgumentException
+ */
+ public function test__read(ResourceStream $stream, string $expected)
+ {
+ $this->assertSame($expected, $stream->read(strlen($expected)));
+ }
+
+ public function readProvider(): array
+ {
+ $resource = fopen(__FILE__, 'rb');
+ $me = new ResourceStream($resource);
+
+ return [
+ [$me, '<'], // Read for byte
+ [$me, '?php'], // Read following bytes. File statrts with "read($length);
+ }
+
+ public function readBadLengthProvider(): array
+ {
+ $resource = fopen(__FILE__, 'rb');
+ $me = new ResourceStream($resource);
+
+ return [
+ [$me, 0],
+ [$me, -1],
+ ];
+ }
+
+ /**
+ * Test read to invalid resource
+ *
+ * @covers ::read
+ * @dataProvider readBadResourceProvider
+ * @expectedException \RouterOS\Exceptions\StreamException
+ *
+ * @param ResourceStream $stream Cannot typehint, PHP refuse it
+ * @param int $length
+ */
+ public function test__readBadResource(ResourceStream $stream, int $length)
+ {
+ $stream->read($length);
+ }
+
+ public function readBadResourceProvider(): array
+ {
+ $resource = fopen(__FILE__, 'rb');
+ $me = new ResourceStream($resource);
+ fclose($resource);
+ return [
+ [$me, 1],
+ ];
+ }
+
+ /**
+ * Test that write function returns writen length
+ *
+ * @covers ::write
+ * @dataProvider writeProvider
+ *
+ * @param ResourceStream $stream to test
+ * @param string $toWrite the writed string
+ * @throws \RouterOS\Exceptions\StreamException
+ */
+ public function test__write(ResourceStream $stream, string $toWrite)
+ {
+ $this->assertEquals(strlen($toWrite), $stream->write($toWrite));
+ }
+
+ public function writeProvider(): array
+ {
+ $resource = fopen('/dev/null', 'wb');
+ $null = new ResourceStream($resource);
+
+ return [
+ [$null, 'yyaagagagag'], // Take that
+ ];
+ }
+
+ /**
+ * Test write to invalid resource
+ *
+ * @covers ::write
+ * @dataProvider writeBadResourceProvider
+ * @expectedException \RouterOS\Exceptions\StreamException
+ *
+ * @param ResourceStream $stream to test
+ * @param string $toWrite the written string
+ */
+ public function test__writeBadResource(ResourceStream $stream, string $toWrite)
+ {
+ $stream->write($toWrite);
+ }
+
+ public function writeBadResourceProvider(): array
+ {
+ $resource = fopen('/dev/null', 'wb');
+ $me = new ResourceStream($resource);
+ fclose($resource);
+
+ return [
+ [$me, 'sasasaas'], // Take that
+ ];
+ }
+
+ /**
+ * Test double close resource
+ *
+ * @covers ::close
+ * @dataProvider doubleCloseProvider
+ * @expectedException \RouterOS\Exceptions\StreamException
+ *
+ * @param ResourceStream $stream to test
+ */
+ public function test_doubleClose(ResourceStream $stream)
+ {
+ $stream->close();
+ $stream->close();
+ }
+
+ public function doubleCloseProvider(): array
+ {
+ return [
+ [new ResourceStream(fopen('/dev/null', 'wb')), 'sasasaas'], // Take that
+ ];
+ }
+
+ /**
+ * Test write to closed resource
+ *
+ * @covers ::close
+ * @covers ::write
+ * @dataProvider writeClosedResourceProvider
+ * @expectedException \RouterOS\Exceptions\StreamException
+ *
+ * @param ResourceStream $stream to test
+ * @param string $toWrite the written string
+ */
+ public function test_close(ResourceStream $stream, string $toWrite)
+ {
+ $stream->close();
+ $stream->write($toWrite);
+ }
+
+ public function writeClosedResourceProvider(): array
+ {
+ return [
+ [new ResourceStream(fopen('/dev/null', 'wb')), 'sasasaas'], // Take that
+ ];
+ }
+
+}
diff --git a/tests/Streams/StringStreamTest.php b/tests/Streams/StringStreamTest.php
new file mode 100644
index 0000000..4fb4f1c
--- /dev/null
+++ b/tests/Streams/StringStreamTest.php
@@ -0,0 +1,160 @@
+assertInstanceOf(StringStream::class, new StringStream($string));
+ }
+
+ public function constructProvider(): array
+ {
+ return [
+ [chr(0)],
+ [''],
+ ['1'],
+ ['lkjl' . chr(0) . 'kjkljllkjkljljklkjkljlkjljlkjkljkljlkjjll'],
+ ];
+ }
+
+
+ /**
+ * Test that write function returns the effective written bytes
+ *
+ * @covers ::write
+ * @dataProvider writeProvider
+ *
+ * @param string $string the string to write
+ * @param int|null $length the count if bytes to write
+ * @param int $expected the number of bytes that must be writen
+ */
+
+ public function test__write(string $string, $length, int $expected)
+ {
+ $stream = new StringStream('Does not matters');
+ if (null === $length) {
+ $this->assertEquals($expected, $stream->write($string));
+ } else {
+ $this->assertEquals($expected, $stream->write($string, $length));
+ }
+
+ }
+
+ public function writeProvider(): array
+ {
+ return [
+ ['', 0, 0],
+ ['', 10, 0],
+ ['', null, 0],
+ ['Yabala', 0, 0],
+ ['Yabala', 1, 1],
+ ['Yabala', 6, 6],
+ ['Yabala', 100, 6],
+ ['Yabala', null, 6],
+ [chr(0), 0, 0],
+ [chr(0), 1, 1],
+ [chr(0), 100, 1],
+ [chr(0), null, 1],
+ ];
+ }
+
+ /**
+ * @covers ::write
+ * @expectedException \InvalidArgumentException
+ */
+ public function test__writeWithNegativeLength()
+ {
+ $stream = new StringStream('Does not matters');
+ $stream->write('PLOP', -1);
+ }
+
+ /**
+ * Test read function
+ *
+ * @throws \RouterOS\Exceptions\StreamException
+ */
+ public function test__read()
+ {
+ $stream = new StringStream('123456789');
+
+ $this->assertEquals('', $stream->read(0));
+ $this->assertEquals('1', $stream->read(1));
+ $this->assertEquals('23', $stream->read(2));
+ $this->assertEquals('456', $stream->read(3));
+ $this->assertEquals('', $stream->read(0));
+ $this->assertEquals('789', $stream->read(4));
+ }
+
+ /**
+ * @expectedException \InvalidArgumentException
+ *
+ * @throws \RouterOS\Exceptions\StreamException
+ */
+ public function test__readBadLength()
+ {
+ $stream = new StringStream('123456789');
+ $stream->read(-1);
+ }
+
+ /**
+ * @covers ::read
+ * @dataProvider readWhileEmptyProvider
+ * @expectedException \RouterOS\Exceptions\StreamException
+ *
+ * @param StringStream $stream
+ * @param int $length
+ * @throws \RouterOS\Exceptions\StreamException
+ */
+ public function test__readWhileEmpty(StringStream $stream, int $length)
+ {
+ $stream->read($length);
+ }
+
+ /**
+ * @return \Generator
+ * @throws StreamException
+ */
+ public function readWhileEmptyProvider()
+ {
+ $stream = new StringStream('123456789');
+ $stream->read(9);
+ yield [$stream, 1];
+
+ $stream = new StringStream('123456789');
+ $stream->read(5);
+ $stream->read(4);
+ yield [$stream, 1];
+
+ $stream = new StringStream('');
+ yield [$stream, 1];
+ }
+
+ /**
+ * @expectedException \RouterOS\Exceptions\StreamException
+ */
+ public function testReadClosed()
+ {
+ $stream = new StringStream('123456789');
+ $stream->close();
+ $stream->read(1);
+ }
+}