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); + } +}