/ * @todo verify need to call disconnect() on setClient/Password/etc. */ abstract class P4_Connection_Abstract implements P4_Connection_Interface { const LOG_MAX_STRING_LENGTH = 1024; const DEFAULT_CHARSET = 'utf8unchecked'; const OPTION_LIMIT = 256; protected $_client; protected $_info; protected $_password; protected $_port; protected $_ticket; protected $_user; protected $_charset; protected $_host; protected $_appName; protected $_disconnectCallbacks = array(); /** * Create a P4_Connection_Interface instance. * * @param string $port optional - the port to connect to. * @param string $user optional - the user to connect as. * @param string $client optional - the client spec to use. * @param string $password optional - the password to use. * @param string $ticket optional - a ticket to use. */ public function __construct( $port = null, $user = null, $client = null, $password = null, $ticket = null) { $this->setPort($port); $this->setUser($user); $this->setClient($client); $this->setPassword($password); $this->setTicket($ticket); // ensure we disconnect on shutdown. P4_Environment::addShutdownCallback( array($this, 'disconnect') ); } /** * Return the p4 port. * * @return string the port. */ public function getPort() { return $this->_port; } /** * Set the p4 port. * Forces a disconnect if already connected. * * @param string $port the port to connect to. * @return P4_Connection_Interface provides fluent interface. * @todo validate port using port validator - make validator work with 'rsh:' ports. */ public function setPort($port) { $this->_port = (string) $port; // disconnect on port change. $this->disconnect(); return $this; } /** * Return the name of the p4 user. * * @return string the user. */ public function getUser() { return $this->_user; } /** * Set the name of the p4 user. * Forces a disconnect if already connected. * * @param string $user the user to connect as. * @return P4_Connection_Interface provides fluent interface. */ public function setUser($user) { $validator = new P4_Validate_SpecName; if ($user !== null && !$validator->isValid($user)) { throw new P4_Exception("Username: " . implode("\n", $validator->getMessages())); } $this->_user = $user; // disconnect on user change. $this->disconnect(); return $this; } /** * Return the p4 user's client. * * @return string the client. */ public function getClient() { return $this->_client; } /** * Set the p4 user's client. * Forces a disconnect if already connected. * * @param string $client the name of the client workspace to use. * @return P4_Connection_Interface provides fluent interface. */ public function setClient($client) { $validator = new P4_Validate_SpecName; if ($client !== null && !$validator->isValid($client)) { throw new P4_Exception("Client name: " . implode("\n", $validator->getMessages())); } $this->_client = $client; // clear cached p4 info $this->_info = null; return $this; } /** * Retrieves the password set for this perforce connection. * * @return string password used to authenticate against perforce server. */ public function getPassword() { return $this->_password; } /** * Sets the password to use for this perforce connection. * * @param string $password the password to use as authentication. * @return P4_Connection_Interface provides fluent interface. */ public function setPassword($password) { $this->_password = $password; return $this; } /** * Retrieves the ticket set for this perforce connection. * * @return string ticket as generated by perforce server. */ public function getTicket() { return $this->_ticket; } /** * Sets the ticket to use for this perforce connection. * Forces a disconnect if already connected. * * @param string $ticket the ticket to use as authentication. * @return P4_Connection_Interface provides fluent interface. */ public function setTicket($ticket) { $this->_ticket = $ticket; // disconnect on ticket change. $this->disconnect(); return $this; } /** * Retrieves the character set used by this connection. * * @return string charset used for this connection. */ public function getCharset() { return $this->_charset; } /** * Sets the character set to use for this perforce connection. * * You should only set a character set when connecting to a * 'unicode enabled' server, or when setting the special value * of 'none'. * * @param string $charset the charset to use (e.g. 'utf8'). * @return P4_Connection_Interface provides fluent interface. */ public function setCharset($charset) { $this->_charset = $charset; return $this; } /** * Retrieves the client host set for this connection. * * @return string host name used for this connection. */ public function getHost() { return $this->_host; } /** * Sets the client host name overriding the environment. * * @param string|null $host the host name to use. * @return P4_Connection_Interface provides fluent interface. */ public function setHost($host) { $this->_host = $host; return $this; } /** * Set the name of the application that is using this connection. * * The application name will be reported to the server and might * be necessary to satisfy certain licensing restrictions. * * @param string|null $name the app name to report to the server. * @return P4_Connection_Interface provides fluent interface. */ public function setAppName($name) { $this->_appName = is_null($name) ? $name : (string) $name; return $this; } /** * Get the application name being reported to the server. * * @return string|null the app name reported to the server. */ public function getAppName() { return $this->_appName; } /** * Get the current client's root directory with no trailing slash. * * @return string the full path to the current client's root. */ public function getClientRoot() { $info = $this->getInfo(); if (isset($info['clientRoot'])) { return rtrim($info['clientRoot'], '/\\'); } return false; } /** * Return an array of connection information. * Due to caching, server date may be stale. * * @return array the connection information ('p4 info'). */ public function getInfo() { // if info cache is populated and connection is up, return cached info. if (isset($this->_info) && $this->isConnected()) { return $this->_info; } // run p4 info. $result = $this->run("info"); $this->_info = array(); // gather all data (multiple arrays returned when connecting through broker). foreach ($result->getData() as $data) { $this->_info += $data; } return $this->_info; } /** * Clear the info cache. This method is primarily used during testing, * and would not normally be used. * * @return P4_Connection_Interface provides fluent interface. */ public function clearInfo() { $this->_info = null; return $this; } /** * Authenticate the user with 'p4 login'. * * @return string|null the ticket issued by the server or null if * no ticket issued (ie. user has no password). * @throws P4_Connection_LoginException if login fails. */ public function login() { // ensure user name is set. if (!strlen($this->getUser())) { throw new P4_Connection_LoginException( "Login failed. Username is empty.", P4_Connection_LoginException::IDENTITY_AMBIGUOUS ); } // try to login. try { $result = $this->run('login', '-p', $this->_password ?: ''); } catch (P4_Connection_CommandException $e) { // user doesn't exist. if (stristr($e->getMessage(), "doesn't exist") || stristr($e->getMessage(), "has not been enabled by 'p4 protect'") ) { throw new P4_Connection_LoginException( "Login failed. " . $e->getMessage(), P4_Connection_LoginException::IDENTITY_NOT_FOUND ); } // invalid password. if (stristr($e->getMessage(), "password invalid")) { throw new P4_Connection_LoginException( "Login failed. " . $e->getMessage(), P4_Connection_LoginException::CREDENTIAL_INVALID ); } // generic login exception. throw new P4_Connection_LoginException( "Login failed. " . $e->getMessage() ); } // check if no password set for this user. // fail if a password was provided - succeed otherwise. if (stristr($result->getData(0), "no password set for this user")) { if ($this->_password) { throw new P4_Connection_LoginException( "Login failed. " . $result->getData(0), P4_Connection_LoginException::CREDENTIAL_INVALID ); } else { return null; } } // capture ticket from output. $this->_ticket = $result->getData(0); // if ticket wasn't captured correctly, fail with unknown code. if (!$this->_ticket) { throw new P4_Connection_LoginException( "Login failed. Unable to capture login ticket." ); } return $this->_ticket; } /** * Executes the specified command and returns a perforce result object. * No need to call connect() first. Run will connect automatically. * * Performs common pre/post-run work. Hands off to _run() for the * actual mechanics of running commands. * * @param string $command the command to run. * @param array|string $params optional - one or more arguments. * @param array|string $input optional - input for the command - should be provided * in array form when writing perforce spec records. * @param boolean $tagged optional - true/false to enable/disable tagged output. * defaults to true. * @return P4_Result the perforce result object. */ public function run($command, $params = array(), $input = null, $tagged = true) { // establish connection to perforce server. if (!$this->isConnected()) { $this->connect(); } // ensure params is an array. if (!is_array($params)) { if (!empty($params)) { $params = array($params); } else { $params = array(); } } // log the start of the command w. params. $message = "P4 (" . spl_object_hash($this) . ") start command: " . $command . " " . implode(" ", $params); P4_Log::log( substr($message, 0, static::LOG_MAX_STRING_LENGTH), P4_Log::DEBUG ); // prepare input for passing to perforce. $input = $this->_prepareInput($input, $command); // defer to sub-classes to actually issue the command. $result = $this->_run($command, $params, $input, $tagged); // log errors - log them and throw an exception. if ($result->hasErrors()) { // if we have no charset, and the command failed because we are // talking to a unicode server, automatically use the default // charset and run the command again. $errors = $result->getErrors(); $needle = 'Unicode server permits only unicode enabled clients.'; if (!$this->getCharset() && stripos($errors[0], $needle) !== false) { $this->setCharset(static::DEFAULT_CHARSET); // run the command again now that we have a charset. return call_user_func_array( array($this, 'run'), func_get_args() ); } // if connect failed due to an untrusted server, trust it and retry $needle = "To allow connection use the 'p4 trust' command"; if (stripos($errors[0], $needle) !== false && !$this->_hasTrusted) { // add a property to avoid re-recursing on this test $this->_hasTrusted = true; // trust the connection as this is the first time we have seen it $this->run('trust', '-y'); // run the command again now that we have trusted it return call_user_func_array( array($this, 'run'), func_get_args() ); } $message = "P4 (" . spl_object_hash($this) . ") command failed: " . implode("\n", $result->getErrors()); P4_Log::log( substr($message, 0, static::LOG_MAX_STRING_LENGTH), P4_Log::ERR ); $this->_handleError($result); } return $result; } /** * Check if the user we are connected as has super user privileges. * * @return bool true if the user has super, false otherwise. */ public function isSuperUser() { try { $result = $this->run("protects", "-m"); } catch (P4_Connection_CommandException $e) { // if protections table is empty, everyone is super. $errors = $e->getResult()->getErrors(); if (stristr($errors[0], "empty")) { return true; } else if (stristr($errors[0], "password must be set")) { return false; } throw $e; } if ($result->getData(0, "permMax") == "super") { return true; } else { return false; } } /** * Check if the server we are connected to is case sensitive. * * @return bool true if the server is case sensitive, false otherwise. * @throws P4_Exception if unable to determine server case handling. */ public function isCaseSensitive() { $info = $this->getInfo(); // throw exception if case handling unknown. if (!isset($info['caseHandling'])) { throw new P4_Exception("Cannot determine server case-handling."); } return $info['caseHandling'] === 'sensitive'; } /** * Check if the server we are connected to is using external authentication * * @return bool true if the server is using external authentication, false otherwise. */ public function hasExternalAuth() { $info = $this->getInfo(); if (isset($info['externalAuth']) && ($info['externalAuth'] === 'enabled')) { return true; } return false; } /** * Check if the server we are connected to has a auth-set trigger configured. * * @return bool true, if the server has configured an auth-set trigger, * false, otherwise. */ public function hasAuthSetTrigger() { // exit early if the server is not using external authentication if (!$this->hasExternalAuth()) { return false; } try { // try to set the password, the server without an auth-set trigger // throws a P4_Connection_CommandException with the error message: // "Command unavailable: external authentication 'auth-set' trigger not found." $this->run('passwd'); } catch (P4_Connection_CommandException $e) { if (stristr($e->getMessage(), "'auth-set' trigger not found.")) { return false; } } return true; } /** * Connect to a Perforce Server. * Hands off to _connect() for the actual mechanics of connecting. * * @return P4_Connection_Interface provides fluent interface. * @throws P4_Connection_ConnectException if the connection fails. */ public function connect() { if (!$this->isConnected()) { // refuse to connect if no port or no user set. if (!strlen($this->getPort()) || !strlen($this->getUser())) { throw new P4_Connection_ConnectException( "Cannot connect. You must specify both a port and a user." ); } $this->_connect(); } return $this; } /** * Run disconnect callbacks. * * @return P4_Connection_Interface provides fluent interface. */ public function disconnect() { return $this->runDisconnectCallbacks(); } /** * Add a function to run when connection is closed. * Callbacks are removed after they are executed * unless persistent is set to true. * * @param callable $callback the function to execute on disconnect * (will be passed connection). * @param bool $persistent optional - defaults to false - set to true to * run callback on repeated disconnects. * @return P4_Connection_Interface provides fluent interface. */ public function addDisconnectCallback($callback, $persistent = false) { if (!is_callable($callback)) { throw new InvalidArgumentException( "Cannot add disconnect callback. Not callable" ); } $this->_disconnectCallbacks[] = array( 'callback' => $callback, 'persistent' => $persistent ); return $this; } /** * Clear disconnect callbacks. * * @return P4_Connection_Interface provides fluent interface. */ public function clearDisconnectCallbacks() { $this->_disconnectCallbacks = array(); return $this; } /** * Run disconnect callbacks. * * @return P4_Connection_Interface provides fluent interface. */ public function runDisconnectCallbacks() { foreach ($this->_disconnectCallbacks as $key => $callback) { call_user_func($callback['callback'], $this); if (!$callback['persistent']) { unset($this->_disconnectCallbacks[$key]); } } return $this; } /** * Get the server's security level. * * @return int the security level of the server (e.g. 0, 1, 2, 3) */ public function getSecurityLevel() { if (!P4_Counter::exists('security', $this)) { return 0; } return (int) P4_Counter::fetch('security', $this)->getValue(); } /** * This function will throw the appropriate exception for the error(s) found * in the passed result object. * * @param P4_Result $result The result containing errors */ public function _handleError($result) { $message = "Command failed: " . implode("\n", $result->getErrors()); // create appropriate exception based on error condition if (preg_match("/must (sync\/ )?(be )?resolved?/", $message) || preg_match("/Merges still pending/", $message)) { $e = new P4_Connection_ConflictException($message); } else { $e = new P4_Connection_CommandException($message); } $e->setConnection($this); $e->setResult($result); throw $e; } /** * Get the maximum allowable length of all command arguments. * * @return int the max length of combined arguments - zero for no limit */ public function getArgMax() { return 0; } /** * Return arguments split into chunks (batches) where each batch contains as many * arguments as possible to not exceed ARG_MAX or OPTION_LIMIT. * * ARG_MAX is a character limit that affects command line programs (p4). * OPTION_LIMIT is a server-side limit on the number of flags (e.g. '-n'). * * @param array $arguments list of arguments to split into chunks. * @param array|null $prefixArgs arguments to begin all batches with. * @param array|null $suffixArgs arguments to end all batches with. * @param int $groupSize keep arguments together in groups of this size * for example, when clearing attributes you want to * keep pairs of -n and attr-name together. * @return array list of batches of arguments where every batch contains as many * arguments as possible and arg-max is not exceeded. * @throws P4_Exception if a argument (or set of arguments) exceed arg-max. */ public function batchArgs(array $arguments, array $prefixArgs = null, array $suffixArgs = null, $groupSize = 1) { $argMax = $this->getArgMax(); // determine size of leading and trailing arguments. $initialLength = 0; $initialOptions = 0; $prefixArgs = (array) $prefixArgs; $suffixArgs = (array) $suffixArgs; foreach (array_merge($prefixArgs, $suffixArgs) as $argument) { // if we have an arg-max limit, determine length of common args. // compute length by adding length of escaped argument + 1 space if ($argMax) { $initialLength += strlen(static::escapeArg($argument)) + 1; } // if the first character is a dash ('-'), it's an option if (substr($argument, 0, 1) === '-') { $initialOptions++; } } $batches = array(); while (!empty($arguments)) { // determine how many arguments we can move into this batch. $count = 0; $length = $initialLength; $options = $initialOptions; foreach ($arguments as $argument) { // if we have an arg-max limit, enforce it. // compute length by adding length of escaped argument + 1 space if ($argMax) { $length += strlen(static::escapeArg($argument)) + 1; // if we exceed arg-max, break if ($length >= $argMax) { break; } } // if we exceed the option-limit, break if ($options > static::OPTION_LIMIT) { break; } // if the first character is a dash ('-'), it's an option if (substr($argument, 0, 1) === '-') { $options++; } $count++; } // adjust count down to largest divisible group size // and move that number of arguments into this batch. $count -= $count % $groupSize; $batches[] = array_merge($prefixArgs, array_splice($arguments, 0, $count), $suffixArgs); // handle the case of a given argument group not fitting in a batch // this informs the caller of indivisble args and avoids infinite loops if (!empty($arguments) && $count < $groupSize) { throw new P4_Exception( "Cannot batch arguments. Arguments exceed arg-max and/or option-limit." ); } } return $batches; } /** * Escape a string for use as a command argument. * Escaping is a no-op for the abstract implementation, * but is needed by batchArgs. * * @param string $arg the string to escape * @return string the escaped string */ public static function escapeArg($arg) { return $arg; } /** * Actually issues a command. Called by run() to perform the dirty work. * * @param string $command the command to run. * @param array $params optional - arguments. * @param array|string $input optional - input for the command - should be provided * in array form when writing perforce spec records. * @param boolean $tagged optional - true/false to enable/disable tagged output. * defaults to true. * @return P4_Result the perforce result object. */ abstract protected function _run($command, $params = array(), $input = null, $tagged = true); /** * Prepare input for passing to Perforce. * * @param string|array $input the input to prepare for p4. * @param string $command the command to prepare input for. * @return string|array the prepared input. */ abstract protected function _prepareInput($input, $command); /** * Does real work of establishing connection. Called by connect(). * * @throws P4_Connection_ConnectException if the connection fails. */ abstract protected function _connect(); }