<?php
/**
* Abstract class for Perforce Connection implementations.
*
* @copyright 2011 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
* @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();
}