Skip to content
4 changes: 2 additions & 2 deletions src/Command/Pull/PullCommandBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,10 @@ private function importDatabaseDump(string $localDumpFilepath, string $dbHost, s
'mysql',
]);
if ($this->localMachineHelper->commandExists('pv')) {
$command = 'pv "${:LOCAL_DUMP_FILEPATH}" --bytes --rate | gunzip | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"';
$command = 'bash -o pipefail -c "pv \\"$LOCAL_DUMP_FILEPATH\\" --bytes --rate | gunzip | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""';
} else {
$this->io->warning('Install `pv` to see progress bar');
$command = 'gunzip -c "${:LOCAL_DUMP_FILEPATH}" | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"';
$command = 'bash -o pipefail -c "gunzip -c \\"$LOCAL_DUMP_FILEPATH\\" | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""';
}

$env = [
Expand Down
13 changes: 12 additions & 1 deletion src/Command/Push/PushDatabaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,21 @@ private function uploadDatabaseDump(
return $remoteFilepath;
}

/**
* Import database dump on remote server.
* Manually escapes single quotes for bash command safety.
*/
private function importDatabaseDumpOnRemote(EnvironmentResponse $environment, string $remoteDumpFilepath, DatabaseResponse $database): void
{
$this->logger->debug("Importing $remoteDumpFilepath to MySQL on remote machine");
$command = "pv $remoteDumpFilepath --bytes --rate | gunzip | MYSQL_PWD=$database->password mysql --host={$this->getHostFromDatabaseResponse($environment, $database)} --user=$database->user_name {$this->getNameFromDatabaseResponse($database)}";
// Manually escape for single-quoted bash strings (replace ' with '\'' - end quote, escaped quote, start quote)
$host = str_replace("'", "'\\''", $this->getHostFromDatabaseResponse($environment, $database));
$user = str_replace("'", "'\\''", $database->user_name);
$dbName = str_replace("'", "'\\''", $this->getNameFromDatabaseResponse($database));
$password = str_replace("'", "'\\''", $database->password);
// @infection-ignore-all System-generated path under our control, defensive escaping
$filepath = str_replace("'", "'\\''", $remoteDumpFilepath);
$command = "bash -o pipefail -c 'pv '$filepath' --bytes --rate | gunzip | MYSQL_PWD='$password' mysql --host='$host' --user='$user' '$dbName''";
$process = $this->sshHelper->executeCommand($environment->sshUrl, [$command], ($this->output->getVerbosity() > OutputInterface::VERBOSITY_NORMAL));
if (!$process->isSuccessful()) {
throw new AcquiaCliException('Unable to import database on remote machine. {message}', ['message' => $process->getErrorOutput()]);
Expand Down
2 changes: 1 addition & 1 deletion tests/phpunit/src/Commands/Pull/PullCommandTestBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@ protected function mockExecuteMySqlImport(
$this->mockExecutePvExists($localMachineHelper, $pvExists);
$process = $this->mockProcess($success);
$filePath = Path::join(sys_get_temp_dir(), "$env-$dbName-$dbMachineName-$createdAt.sql.gz");
$command = $pvExists ? 'pv "${:LOCAL_DUMP_FILEPATH}" --bytes --rate | gunzip | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"' : 'gunzip -c "${:LOCAL_DUMP_FILEPATH}" | MYSQL_PWD="${:MYSQL_PASSWORD}" mysql --host="${:MYSQL_HOST}" --user="${:MYSQL_USER}" "${:MYSQL_DATABASE}"';
$command = $pvExists ? 'bash -o pipefail -c "pv \\"$LOCAL_DUMP_FILEPATH\\" --bytes --rate | gunzip | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""' : 'bash -o pipefail -c "gunzip -c \\"$LOCAL_DUMP_FILEPATH\\" | MYSQL_PWD=\\"$MYSQL_PASSWORD\\" mysql --host=\\"$MYSQL_HOST\\" --user=\\"$MYSQL_USER\\" \\"$MYSQL_DATABASE\\""';
$expectedEnv = [
'LOCAL_DUMP_FILEPATH' => $filePath,
'MYSQL_DATABASE' => $localDbName,
Expand Down
74 changes: 73 additions & 1 deletion tests/phpunit/src/Commands/Push/PushDatabaseCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,79 @@ private function mockImportDatabaseDumpOnRemote(ObjectProphecy|LocalMachineHelpe
3 => '-o StrictHostKeyChecking=no',
4 => '-o AddressFamily inet',
5 => '-o LogLevel=ERROR',
6 => 'pv /mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz --bytes --rate | gunzip | MYSQL_PWD=password mysql --host=fsdb-74.enterprise-g1.hosting.acquia.com.enterprise-g1.hosting.acquia.com --user=s164 profserv2db14390',
6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='password' mysql --host='fsdb-74.enterprise-g1.hosting.acquia.com.enterprise-g1.hosting.acquia.com' --user='s164' 'profserv2db14390''",
];
$localMachineHelper->execute($cmd, Argument::type('callable'), null, $printOutput, null, null)
->willReturn($process->reveal())
->shouldBeCalled();
}

/**
* Test that special characters in passwords are properly escaped.
*/
public function testPushDatabaseWithSpecialCharsInPassword(): void
{
$applications = $this->mockRequest('getApplications');
$application = $this->mockRequest('getApplicationByUuid', $applications[self::$INPUT_DEFAULT_CHOICE]->uuid);
$tamper = function ($responses): void {
foreach ($responses as $response) {
$response->ssh_url = 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com';
$response->domains = ["profserv201dev.enterprise-g1.acquia-sites.com"];
}
};
$environments = $this->mockRequest('getApplicationEnvironments', $application->uuid, null, null, $tamper);
$this->createMockGitConfigFile();

// Mock database with special characters in password, username, hostname, and database name.
$databases = $this->mockAcsfDatabasesResponse($environments[self::$INPUT_DEFAULT_CHOICE]);
$databases[0]->password = "pass'word";
$databases[0]->user_name = "user'name";
$databases[0]->db_host = "db'host";
$databases[0]->url = "mysqli://s164:password@127.0.0.1:3306/db'name";

$process = $this->mockProcess();
$localMachineHelper = $this->mockLocalMachineHelper();
$localMachineHelper->checkRequiredBinariesExist(['ssh'])
->shouldBeCalled();
$this->mockGetAcsfSitesLMH($localMachineHelper);

$this->mockExecutePvExists($localMachineHelper, true);
$this->mockCreateMySqlDumpOnLocal($localMachineHelper, true, true);
$this->mockUploadDatabaseDump($localMachineHelper, $process, true);
$this->mockImportDatabaseDumpOnRemoteWithSpecialChars($localMachineHelper, $process, true);

$this->command->sshHelper = new SshHelper($this->output, $localMachineHelper->reveal(), $this->logger);

$inputs = [
// Would you like Acquia CLI to search for a Cloud application?
'n',
// Select a Cloud Platform application.
0,
// Would you like to link the project?
'n',
// Choose a Cloud Platform environment.
0,
// Choose a database.
0,
// Overwrite the database?
'y',
];

$this->executeCommand([], $inputs, OutputInterface::VERBOSITY_VERY_VERBOSE);
$this->prophet->checkPredictions();
}

private function mockImportDatabaseDumpOnRemoteWithSpecialChars(ObjectProphecy|LocalMachineHelper $localMachineHelper, Process|ObjectProphecy $process, bool $printOutput = true): void
{
// Verify the command has properly escaped single quotes using '\'' pattern.
$cmd = [
0 => 'ssh',
1 => 'profserv2.01dev@profserv201dev.ssh.enterprise-g1.acquia-sites.com',
2 => '-t',
3 => '-o StrictHostKeyChecking=no',
4 => '-o AddressFamily inet',
5 => '-o LogLevel=ERROR',
6 => "bash -o pipefail -c 'pv '/mnt/tmp/profserv2.01dev/acli-mysql-dump-drupal.sql.gz' --bytes --rate | gunzip | MYSQL_PWD='pass'\\''word' mysql --host='db'\\''host.enterprise-g1.hosting.acquia.com' --user='user'\\''name' 'db'\\''name''",
];
$localMachineHelper->execute($cmd, Argument::type('callable'), null, $printOutput, null, null)
->willReturn($process->reveal())
Expand Down