commit c9aa3c04e394d5bdfc02e43dfaed3475f0f21d47 Author: lwb Date: Mon Mar 31 11:14:13 2025 +0200 init commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..72096fc --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +### rsyncer +------------------ + +#### A thin rsync wrapper class for PHP without any dependencies + +##### Requirements +------------------ +PHP 5.6+, rsync, composer + +##### Installation +------------------ +- Use [Composer](https://getcomposer.org/doc/01-basic-usage.md) to install the package + +- From project root directory execute + + ```composer install``` + + or + + ```composer require codeplayr/rsyncer``` + +- [Composer](https://getcomposer.org/doc/01-basic-usage.md) will take care of autoloading. Just include the autoloader at the top of the file + + ```require_once __DIR__ . '/vendor/autoload.php';``` + + +##### Usage +------------------ + +See following example: + +```php +use \Codeplayr\Rsyncer\Option; +use \Codeplayr\Rsyncer\SSH; +use \Codeplayr\Rsyncer\Rsync; + +$source = __DIR__ . '/src/'; +$destination = __DIR__ . '/backup/'; + +$date = date('Y-m-d', time()); + +//rsync options +$option = new Option([ + Option::FILES_FROM => __DIR__ . '/rules.txt', + Option::EXCLUDE_FROM=> __DIR__ . '/exclude-rules.txt', + Option::LOG_FILE => __DIR__ . "/logs/{$date}.log", + Option::ARCHIVE => false, + Option::LINKS => true, + Option::TIMES => true, + Option::RECURSIVE => true, + Option::VERBOSE => true, + Option::COMPRESS => true, + Option::CHECKSUM => true, + Option::DRY_RUN => false, +]); + +//add additional flags +$option->addFlag('human-readable') + ->addArgument('exclude', '/path/to/exclude') + ->addArgument('include', '*.html') + ->addArgument('include', '*.php') + ->addArgument('include', '*/') + ->addArgument('exclude', '*'); + +//optional ssh connection to remote host +$ssh = new SSH([ + SSH::USERNAME => 'user', + SSH::HOST => '1.2.3.4', + SSH::PORT => 22, + SSH::IDENTITY_FILE => '~/.ssh/id_rsa', +]); + +//configuration options +$conf = [ + Rsync::SHOW_OUTPUT => true, +]; + +$rsync = new Rsync( $option, $ssh, $conf ); + +//assemble and show Command +echo $rsync->getCommand( $source, $destination ); + +//start syncing directories +if( ! $rsync->sync( $source, $destination ) ){ + echo $rsync->getMessage()->toString(); +} +``` + +Running the script generates following rsync command and options: + +```shell +rsync + -ltrvzc + --human-readable + --files-from="/path/to/rules.txt" + --exclude-from="/path/to/exclude-rules.txt" + --log-file="/path/to/logs/2016-10-29.log" + --exclude='/path/to/exclude' + --include="*.html" + --include="*.php" + --include="*/" + --exclude="*" + -e="ssh -i ~/.ssh/id_rsa" + user@1.2.3.4:"/path/to/src/" "/path/to/backup/" + +``` + +##### Run Tests: +---------- + +- All tests are inside `tests` folder. +- Execute `composer install --dev phpunit/phpunit` to install phpunit +- Run `phpunit` from inside the tests directory to execute testcase +- Set `--coverage-text` option to show code coverage report in terminal + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..6261dc2 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "codeplayr/rsyncer", + "description": "Lightweight rsync wrapper without dependencies", + "authors": [ + { + "name": "codeplayr", + "email": "w.baryluk@gmx.de" + } + ], + "require": { + "php": ">=5.6.0" + }, + "require-dev": { + }, + "autoload": { + "psr-4": { + "Codeplayr\\Rsyncer\\": "src/" + } + } +} diff --git a/src/Helper.php b/src/Helper.php new file mode 100644 index 0000000..e9eb196 --- /dev/null +++ b/src/Helper.php @@ -0,0 +1,45 @@ + 2 ){ + $left = substr($flag, 0, 2); + $right = substr($flag, 2); + $left = str_replace('-', '' , $left); + $flag = $left . $right; + } + else if( strlen( $flag ) == 2 ){ + $flag = substr($flag, 1); + } + else{ + throw new \InvalidArgumentException('Invalid Flag Value'); + } + + } + + if( empty( $flag ) || $flag[0] == '-' ){ + throw new \InvalidArgumentException('Invalid Assembled Flag'); + } + + return $flag; + } + + public function addDash( $flag ){ + if( ! is_string( $flag ) || empty($flag) ){ + throw new \InvalidArgumentException('No String Value'); + } + + $flag = $this->removeDash( $flag ); + + return (strlen($flag) > 1) ? '--' . $flag : '-' . $flag; + } + +} diff --git a/src/Message.php b/src/Message.php new file mode 100644 index 0000000..8ff068b --- /dev/null +++ b/src/Message.php @@ -0,0 +1,36 @@ + 'Invalid executable', + self::INVALID_FROM_FILE => 'File in from-file option not exists', + self::SUCCESS_EXECUTE => 'Success executing command', + self::ERROR_EXECUTE => 'Error executing command', + self::INVALID_SSH => 'Invalid SSH config', + ]; + + private $_stored_messages = []; + + public function getMessage( $type ){ + return isset( $this->_templates[ $type ]) ? $this->_templates[ $type ] : ''; + } + + public function store( $message ){ + $this->_stored_messages[] = $message; + } + + public function toString(){ + $m = []; + foreach( $this->_stored_messages as $message ) $m[] = $message; + return implode(' ' , $m); + } + +} diff --git a/src/Option.php b/src/Option.php new file mode 100644 index 0000000..8ea795d --- /dev/null +++ b/src/Option.php @@ -0,0 +1,146 @@ + 'a', + self::RECURSIVE => 'r', + self::LINKS => 'l', + self::PERMS => 'p', + self::TIMES => 't', + self::GROUP => 'g', + self::OWNER => 'o', + self::CHECKSUM => 'c', + self::HUMAN_READABLE => 'h', + self::DEVICES => 'D', + self::DRY_RUN => 'n', + self::VERBOSE => 'v', + self::COMPRESS => 'z', + self::PROGRESS => 'progress', + self::DELETE => 'delete', + self::FILES_FROM => 'files-from', + self::EXCLUDE_FROM => 'exclude-from', + self::LOG_FILE => 'log-file', + ]; + + public function __construct( $options = [] ){ + + $this->_helper = new Helper(); + + if( ! is_array( $options ) ) return; + + foreach( $options as $key => $val ){ + $flag = $this->_options_map[ $key ]; + if( $flag && $val === true ){ + $this->addFlag( $flag ); + } + } + + if( key_exists( self::FILES_FROM, $options ) ){ + $this->addArgument( $this->_options_map[ self::FILES_FROM ], $options[ self::FILES_FROM ] ); + } + + if( key_exists( self::EXCLUDE_FROM, $options ) ){ + $this->addArgument( $this->_options_map[ self::EXCLUDE_FROM ], $options[ self::EXCLUDE_FROM] ); + } + + if( key_exists( self::LOG_FILE, $options ) ){ + $this->addArgument( $this->_options_map[ self::LOG_FILE ], $options[ self::LOG_FILE ] ); + } + + } + + public function assemble(){ + $flags = $this->_assembleFlags(); + $opts = implode(' ', $this->_extras); + + return trim( $flags . ' ' . $opts ); + } + + public function addFlag( $flag ){ + if( ! is_string( $flag ) ){ + throw new \InvalidArgumentException('No String Value'); + } + + $flag = $this->_helper->removeDash( $flag ); + + if( $flag == 'a' ) $this->_flags = array_merge( $this->_flags, ['r', 'l', 'p', 't', 'g', 'o', 'D' ] ); + else $this->_flags[] = $flag; + + return $this; + } + + public function removeFlag( $flag ){ + if( ! is_string( $flag ) ){ + throw new \InvalidArgumentException('No String Value'); + } + + $flag = $this->_helper->removeDash( $flag ); + + $idx = array_search( $flag, $this->_flags ); + if( $idx >= 0 ){ + unset( $this->_flags[ $idx ] ); + } + + return $this; + } + + public function addArgument( $name, $value, $useEqualDelimiter = true ){ + if( ! is_string( $name ) ){ + throw new \InvalidArgumentException('No String Value'); + } + + $name = $this->_helper->removeDash( $name ); + $name = $this->_helper->addDash( $name ); + + $this->_extras[] = ( ! $useEqualDelimiter )? $name . ' ' . escapeshellarg( $value ) + : $name . '=' . escapeshellarg( $value ); + + return $this; + } + + private function _assembleFlags(){ + $unique_opts = array_unique( $this->_flags ); + + $flags_single = []; + $flags_multi = []; + + foreach( $unique_opts as $flag ){ + if( strlen($flag) == 1 ) $flags_single[] = $flag; + else $flags_multi[] = '--' . $flag; + } + + $s = []; + $s[] = '-' . implode('', $flags_single); + $s[] = implode(' ', $flags_multi); + + return implode(' ', $s); + } + +} diff --git a/src/Rsync.php b/src/Rsync.php new file mode 100644 index 0000000..b54e08c --- /dev/null +++ b/src/Rsync.php @@ -0,0 +1,141 @@ + 'rsync', + self::SHOW_OUTPUT => false, + ]; + + $this->_conf = array_merge( $defaults, $conf ); + + $this->_message = ($message instanceof Message ) ? $message : new Message(); + $this->_option = $options; + $this->_ssh = $ssh; + } + + public function sync( $source, $destination ){ + $cmd = $this->getCommand( $source, $destination ); + + if( ! $this->_valid ) return false; + + return $this->_execute( $cmd ); + } + + public function getMessage(){ + return $this->_message; + } + + public function getCommand( $source, $destination ){ + if( ! isset( $this->_conf[ self::EXECUTABLE ] ) ){ + if( ! $this->_commandExists('rsync') ){ + $this->_message->store( $this->_message->getMessage(Message::INVALID_EXECUTABLE) ); + $this->_valid = false; + } + } + + return $this->_assembleCommand( $source, $destination ); + } + + private function _execute( $cmd ){ + $b = true; + + if( (bool)$this->_conf[ self::SHOW_OUTPUT ] ){ + $b = $this->_executeWithOutput( $cmd ); + } + else{ + exec($cmd, $output, $error_val); + if( $error_val !== 0 ) $b = false; + } + + if( $b ) $this->_message->store( $this->_message->getMessage( Message::SUCCESS_EXECUTE )); + else $this->_message->store( $this->_message->getMessage( Message::ERROR_EXECUTE )); + + return $b; + } + + private function _executeWithOutput( $command ){ + echo "Execute: " . $command . PHP_EOL; + if( ($fp = popen( $command, "r" )) ){ + while( ! feof( $fp ) ){ + echo fread( $fp, 1024 ); + flush(); + } + fclose($fp); + } + else return false; + return true; + } + + private function _assembleCommand( $source, $destination ){ + + $cmd = []; + + $cmd[] = $this->_conf[ self::EXECUTABLE ]; + + if( $this->_option ){ + $cmd[] = $this->_option->assemble(); + } + + if( is_null( $this->_ssh ) ){ + $cmd[] = escapeshellarg($source); + $cmd[] = escapeshellarg($destination); + } + else{ + $ssh_cmd = $this->_assembleSSHCommand( $source, $destination ); + if( ! $ssh_cmd ) $this->_valid = false; + $cmd[] = $ssh_cmd; + } + + return implode(' ', $cmd); + } + + private function _assembleSSHCommand( $source, $destination ){ + $cmd = null; + + $ssh_command = $this->_ssh->assemble(); + if( ! $ssh_command ){ + $this->_valid = false; + $this->_message->store( $this->_message->getMessage( Message::INVALID_SSH )); + } + else{ + $cmd = []; + $cmd[] = $ssh_command; + + if( $this->_ssh->isRemoteSource() ){ + $cmd[] = $this->_ssh->getHost() . ':' . escapeshellarg($source); + $cmd[] = escapeshellarg($destination); + } + else{ + $cmd[] = escapeshellarg($source); + $cmd[] = $this->_ssh->getHost() . ':' . escapeshellarg($destination); + } + } + + return is_array( $cmd ) ? implode(' ', $cmd) : null; + } + + private function _commandExists( $cmd ){ + $whereCmd = (substr( strtolower(PHP_OS), 0, 3) == 'win') ? 'where' : 'which'; + $val = shell_exec("$whereCmd $cmd");; + return ( empty( $val ) ) ? false : true; + } + +} diff --git a/src/SSH.php b/src/SSH.php new file mode 100644 index 0000000..d0c51a6 --- /dev/null +++ b/src/SSH.php @@ -0,0 +1,89 @@ +_helper = new Helper(); + + $defaults = [ + self::EXECUTABLE => 'ssh', + self::USERNAME => null, + self::HOST => null, + self::PORT => 22, + self::IDENTITY_FILE => null, + self::REMOTE_SOURCE => true, + ]; + + $this->_options = array_merge( $defaults, $options ); + } + + public function assemble(){ + return $this->_assembleCommand(); + } + + public function getHost(){ + return $this->_options[ self::USERNAME ] . '@' . $this->_options[ self::HOST ]; + } + + public function isRemoteSource(){ + return (bool) $this->_options[ self::REMOTE_SOURCE ]; + } + + public function addFlag( $name, $value = null ){ + $name = $this->_helper->removeDash( $name ); + $this->_flags[ $name ] = $value; + } + + private function _assembleCommand(){ + if( ! is_string( $this->_options[ self::USERNAME ] ) ){ + return null; + } + + if( ! is_string( $this->_options[ self::HOST ] ) ){ + return null; + } + + if( ! is_int( $this->_options[ self::PORT ] ) ){ + return null; + } + + if( ! is_string( $this->_options[ self::EXECUTABLE ] ) ){ + return null; + } + + $cmd = []; + $cmd[] = '-e="' . $this->_options[ self::EXECUTABLE ]; + + if( $this->_options[ self::PORT ] != 22 ){ + $cmd[] = "-p {$this->_options[ self::PORT ]}"; + } + + if( is_string( $this->_options[ self::IDENTITY_FILE ] ) ){ + $cmd[] = "-i {$this->_options[ self::IDENTITY_FILE ]}"; + } + + foreach( $this->_flags as $name => $value ){ + $name = $this->_helper->addDash( $name ); + + $cmd[] = ( is_null( $value ) ) ? $name : $name . ' ' . $value; + } + + return implode(' ', $cmd ) . '"'; + } + +} diff --git a/tests/RsyncTest.php b/tests/RsyncTest.php new file mode 100644 index 0000000..a5a3214 --- /dev/null +++ b/tests/RsyncTest.php @@ -0,0 +1,106 @@ + $files_from, + Option::ARCHIVE => false, + Option::LINKS => true, + Option::TIMES => true, + Option::RECURSIVE => true, + Option::VERBOSE => true, + Option::COMPRESS => true, + Option::CHECKSUM => true, + Option::DRY_RUN => false, + ]); + + $identity_file = '/path/to/private/key'; + + $ssh = new SSH([ + SSH::USERNAME => 'root', + SSH::HOST => '192.168.1.101', + SSH::PORT => 22, + SSH::IDENTITY_FILE => $identity_file, + ]); + + $rsync = new Rsync( $option, $ssh ); + + $this->assertInstanceOf('\Codeplayr\Rsyncer\Rsync', $rsync); + + $cmd = $rsync->getCommand( $source, $destination ); + + $this->assertContains('-ltrvzc', $cmd); + $this->assertContains("--files-from=\"{$files_from}\"", $cmd); + $this->assertContains("-e=\"ssh -i {$identity_file}\"", $cmd); + } + + public function test_rsync_execution_with_dryrun_returns_success(){ + $source = realpath( __DIR__ . '/../src/' ); + $destination = __DIR__; + + function transform( $path ){ + $pos = strpos( $path, ':'); + $left = substr($path, 0, $pos); + $right = substr($path, $pos + 1 ); + return str_replace('\\', '/', "/cygdrive/" . $left . $right); + } + + if(substr( strtolower(PHP_OS), 0, 3) == 'win'){ + $source = transform( $source ); + $destination = transform( $destination ); + } + + $option = new Option([ + Option::ARCHIVE => true, + Option::DRY_RUN => true, + ]); + + $rsync = new Rsync( $option, null, [Rsync::SHOW_OUTPUT => true]); + + $this->assertTrue( $rsync->sync( $source, $destination ) ); + } + + public function test_flags_with_dash(){ + $helper = new Helper(); + $this->assertEquals('a', $helper->removeDash('-a')); + $this->assertEquals('archive', $helper->removeDash('--archive')); + $this->assertEquals('archive', $helper->removeDash('archive')); + $this->assertEquals('-a', $helper->addDash('a')); + $this->assertEquals('--archive', $helper->addDash('archive')); + $this->assertEquals('--archive', $helper->addDash('--archive')); + } + + /** + * @dataProvider invalid_flags_dataprovider + */ + public function test_invalid_flag_returns_exception( $flag ){ + $this->setExpectedException('InvalidArgumentException'); + + $helper = new Helper(); + $helper->removeDash( $flag ); + $helper->addDash( $flag ); + } + + public function invalid_flags_dataprovider() { + return [ + [0], + [''], + ['-'], + ['--'] + ]; + } + +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..99d296b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,2 @@ + + + + + ./ + + + + + ./../src/ + + + \ No newline at end of file