init commit

This commit is contained in:
lwb 2025-03-31 11:14:13 +02:00
commit c9aa3c04e3
10 changed files with 713 additions and 0 deletions

115
README.md Normal file
View File

@ -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

20
composer.json Normal file
View File

@ -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/"
}
}
}

45
src/Helper.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace Codeplayr\Rsyncer;
class Helper{
public function removeDash( $flag ){
if( ! is_string( $flag ) || empty($flag) ){
throw new \InvalidArgumentException('No String Value');
}
if( $flag[0] == '-'){
if( strlen( $flag ) > 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;
}
}

36
src/Message.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace Codeplayr\Rsyncer;
class Message{
const INVALID_EXECUTABLE= 'INVALID_EXECUTABLE';
const INVALID_FROM_FILE = 'INVALID_FROM_FILE';
const SUCCESS_EXECUTE = 'SUCCESS_EXECUTE';
const ERROR_EXECUTE = 'ERROR_EXECUTE';
const INVALID_SSH = 'INVALID SSH';
private $_templates = [
self::INVALID_EXECUTABLE=> '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);
}
}

146
src/Option.php Normal file
View File

@ -0,0 +1,146 @@
<?php
namespace Codeplayr\Rsyncer;
use \Codeplayr\Rsyncer\Helper;
class Option{
const ARCHIVE = 'archive'; //equals -rlptgoD (no -H,-A,-X)
const RECURSIVE = 'recursive';
const LINKS = 'links';
const PERMS = 'perms';
const TIMES = 'times';
const GROUP = 'group';
const OWNER = 'owner';
const CHECKSUM = 'checksum';
const HUMAN_READABLE = 'human_readable';
const DEVICES = 'devices';
const DRY_RUN = 'dry_run';
const VERBOSE = 'verbose';
const COMPRESS = 'compress';
const PROGRESS = 'progress';
const DELETE = 'delete';
const FILES_FROM = 'files_from';
const EXCLUDE_FROM = 'exclude_from';
const LOG_FILE = 'log_file';
private $_flags = [];
private $_extras = [];
private $_helper = null;
private $_options_map = [
self::ARCHIVE => '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);
}
}

141
src/Rsync.php Normal file
View File

@ -0,0 +1,141 @@
<?php
namespace Codeplayr\Rsyncer;
use \Codeplayr\Rsyncer\Option;
use \Codeplayr\Rsyncer\Message;
use \Codeplayr\Rsyncer\SSH;
class Rsync{
const EXECUTABLE = 'executable';
const SHOW_OUTPUT = 'show_output';
private $_message = null;
private $_valid = true;
private $_option = null;
private $_ssh = null;
private $_conf = null;
private $_helper = null;
public function __construct( Option $options = null, SSH $ssh = null, $conf = [], $message = null ){
$defaults = [
self::EXECUTABLE => '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;
}
}

89
src/SSH.php Normal file
View File

@ -0,0 +1,89 @@
<?php
namespace Codeplayr\Rsyncer;
use \Codeplayr\Rsyncer\Helper;
class SSH{
const EXECUTABLE = 'executable';
const USERNAME = 'username';
const HOST = 'host';
const PORT = 'port';
const IDENTITY_FILE = 'identity_file';
const REMOTE_SOURCE = 'remote_source';
private $_options = [];
private $_flags = [];
private $_helper = null;
public function __construct( $options = [] ){
$this->_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 ) . '"';
}
}

106
tests/RsyncTest.php Normal file
View File

@ -0,0 +1,106 @@
<?php
use \Codeplayr\Rsyncer\Option;
use \Codeplayr\Rsyncer\SSH;
use \Codeplayr\Rsyncer\Rsync;
use \Codeplayr\Rsyncer\Helper;
class RsyncTest extends PHPUnit_Framework_TestCase{
protected function setUp(){}
public function test_rsync_command_assembling_with_options_returns_valid_command(){
$source = __DIR__ . '/src/';
$destination = __DIR__ . '/backup/';
$files_from = __DIR__ . '/rules.txt';
$option = new Option([
Option::FILES_FROM => $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],
[''],
['-'],
['--']
];
}
}

2
tests/bootstrap.php Normal file
View File

@ -0,0 +1,2 @@
<?php
require_once __DIR__.'/../vendor/autoload.php';

13
tests/phpunit.xml Normal file
View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="bootstrap.php" colors="true">
<testsuites>
<testsuite name="Rsyncer Test Suite">
<directory>./</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">./../src/</directory>
</whitelist>
</filter>
</phpunit>