Skip to content
Snippets Groups Projects
Commit 16af2f04 authored by Lucas Hedding's avatar Lucas Hedding Committed by Lucas Hedding
Browse files

Issue #3050804 by heddn, catch, drumm: Modified code checker

parent 111c3cd8
No related branches found
No related tags found
No related merge requests found
Showing
with 592 additions and 143 deletions
......@@ -55,7 +55,7 @@ function automatic_updates_page_top(array &$page_top) {
}
$results = $checker->getResults('warning');
if ($results) {
\Drupal::messenger()->addWarning(t('Your site does not pass some readiness checks for automatic updates. It might not be completely eligible for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
\Drupal::messenger()->addWarning(t('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might effect the eligibility for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
foreach ($results as $message) {
\Drupal::messenger()->addWarning($message);
}
......
......@@ -48,11 +48,20 @@ services:
- '@automatic_updates.drupal_finder'
tags:
- { name: readiness_checker, category: error}
automatic_updates.modified_code:
class: Drupal\automatic_updates\ReadinessChecker\ModifiedCode
automatic_updates.modified_files:
class: Drupal\automatic_updates\Services\ModifiedFiles
arguments:
- '@logger.channel.automatic_updates'
- '@automatic_updates.drupal_finder'
- '@http_client'
- '@config.factory'
automatic_updates.modified_files_checker:
class: Drupal\automatic_updates\ReadinessChecker\ModifiedFiles
arguments:
- '@automatic_updates.modified_files'
- '@extension.list.module'
- '@extension.list.profile'
- '@extension.list.theme'
tags:
- { name: readiness_checker, category: warning}
automatic_updates.file_ownership:
......
# Public service announcement URI endpoint.
# TODO: Update to correct end point once it is available. See
# https://www.drupal.org/project/automatic_updates/issues/3045273
psa_endpoint: 'http://localhost/automatic_updates/test-json'
psa_endpoint: 'http://example.com/automatic_updates/test-json'
enable_psa: true
notify: true
check_frequency: 43200
enable_readiness_checks: true
download_uri: 'https://ftp.drupal.org/files/projects'
......@@ -17,3 +17,6 @@ automatic_updates.settings:
enable_readiness_checks:
type: boolean
label: 'Enable readiness checks'
download_uri:
type: string
label: 'Endpoint URI for file hashes and quasi patch files'
<?php
namespace Drupal\automatic_updates\ReadinessChecker;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use DrupalFinder\DrupalFinder;
use Psr\Log\LoggerInterface;
/**
* Modified code checker.
*/
class ModifiedCode implements ReadinessCheckerInterface {
use StringTranslationTrait;
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The Drupal root path.
*
* @var string
*/
protected $drupalRoot;
/**
* The vendor path.
*
* @var string
*/
protected $vendorPath;
/**
* The drupal finder service.
*
* @var \DrupalFinder\DrupalFinder
*/
protected $drupalFinder;
/**
* ReadOnlyFilesystem constructor.
*
* @param \Psr\Log\LoggerInterface $logger
* The logger.
* @param \DrupalFinder\DrupalFinder $drupal_finder
* The Drupal finder.
*/
public function __construct(LoggerInterface $logger, DrupalFinder $drupal_finder) {
$this->logger = $logger;
$this->drupalFinder = $drupal_finder;
}
/**
* {@inheritdoc}
*/
public function run() {
$messages = [];
if (!$this->getDrupalRoot()) {
$messages[] = $this->t('The Drupal root directory could not be located.');
return $messages;
}
$this->modifiedCode($messages);
return $messages;
}
/**
* Get the Drupal root path.
*
* @return string
* The Drupal root path.
*/
protected function getDrupalRoot() {
if (!$this->drupalRoot && $this->drupalFinder->locateRoot(getcwd())) {
$this->drupalRoot = $this->drupalFinder->getDrupalRoot();
}
return $this->drupalRoot;
}
/**
* Get the vendor path.
*
* @return string
* The vendor path.
*/
protected function getVendorPath() {
if (!$this->vendorPath && $this->drupalFinder->locateRoot(getcwd())) {
$this->vendorPath = $this->drupalFinder->getVendorDir();
}
return $this->vendorPath;
}
/**
* Check if the site contains any modified code.
*
* @param array $messages
* The messages array of translatable strings.
*/
protected function modifiedCode(array &$messages) {
// TODO: Implement file hashing logic against all code files.
// See: https://www.drupal.org/project/automatic_updates/issues/3050804
}
}
<?php
namespace Drupal\automatic_updates\ReadinessChecker;
use Drupal\automatic_updates\Services\ModifiedFilesInterface;
use Drupal\Core\Extension\ExtensionList;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Modified code checker.
*/
class ModifiedFiles implements ReadinessCheckerInterface {
use StringTranslationTrait;
/**
* The modified files service.
*
* @var \Drupal\automatic_updates\Services\ModifiedFilesInterface
*/
protected $modifiedFiles;
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ExtensionList
*/
protected $modules;
/**
* The profile extension list.
*
* @var \Drupal\Core\Extension\ExtensionList
*/
protected $profiles;
/**
* The theme extension list.
*
* @var \Drupal\Core\Extension\ExtensionList
*/
protected $themes;
/**
* An array of array of strings of extension paths.
*
* @var string[]string[]
*/
protected $paths;
/**
* ModifiedFiles constructor.
*
* @param \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files
* The modified files service.
* The config factory.
* @param \Drupal\Core\Extension\ExtensionList $modules
* The module extension list.
* @param \Drupal\Core\Extension\ExtensionList $profiles
* The profile extension list.
* @param \Drupal\Core\Extension\ExtensionList $themes
* The theme extension list.
*/
public function __construct(ModifiedFilesInterface $modified_files, ExtensionList $modules, ExtensionList $profiles, ExtensionList $themes) {
$this->modifiedFiles = $modified_files;
$this->modules = $modules;
$this->profiles = $profiles;
$this->themes = $themes;
}
/**
* {@inheritdoc}
*/
public function run() {
return $this->modifiedFilesCheck();
}
/**
* Check if the site contains any modified code.
*
* @return array
* An array of translatable strings if any checks fail.
*/
protected function modifiedFilesCheck() {
$messages = [];
$extensions = [];
$extensions['drupal'] = $this->modules->get('system')->info;
foreach ($this->getExtensionsTypes() as $extension_type) {
foreach ($this->getInfos($extension_type) as $extension_name => $info) {
if (substr($this->getPath($extension_type, $extension_name), 0, 4) !== 'core') {
$extensions[$extension_name] = $info;
}
}
}
foreach ($this->modifiedFiles->getModifiedFiles($extensions) as $file) {
$messages[] = $this->t('The hash for @file does not match its original. Updates that include that file will fail and require manual intervention.', ['@file' => $file]);
}
return $messages;
}
/**
* Get the extension types.
*
* @return array
* The extension types.
*/
protected function getExtensionsTypes() {
return ['modules', 'profiles', 'themes'];
}
/**
* Returns an array of info files information of available extensions.
*
* @param string $extension_type
* The extension type.
*
* @return array
* An associative array of extension information arrays, keyed by extension
* name.
*/
protected function getInfos($extension_type) {
return $this->{$extension_type}->getAllAvailableInfo();
}
/**
* Returns an extension file path.
*
* @param string $extension_type
* The extension type.
* @param string $extension_name
* The extension name.
*
* @return string
* An extension file path or NULL if it does not exist.
*/
protected function getPath($extension_type, $extension_name) {
if (!isset($this->paths[$extension_type])) {
$this->paths[$extension_type] = $this->{$extension_type}->getPathnames();
}
return isset($this->paths[$extension_type][$extension_name]) ? $this->paths[$extension_type][$extension_name] : NULL;
}
}
......@@ -54,6 +54,9 @@ class ReadOnlyFilesystem extends Filesystem {
/**
* Check if the filesystem is read only.
*
* @return array
* An array of translatable strings if any checks fail.
*/
protected function readOnlyCheck() {
$messages = [];
......
<?php
namespace Drupal\automatic_updates\Services;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Url;
use DrupalFinder\DrupalFinder;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Promise\EachPromise;
use Psr\Http\Message\ResponseInterface;
use Psr\Log\LoggerInterface;
/**
* Modified files service.
*/
class ModifiedFiles implements ModifiedFilesInterface {
/**
* The logger.
*
* @var \Psr\Log\LoggerInterface
*/
protected $logger;
/**
* The HTTP client.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $httpClient;
/**
* The config factory.
*
* @var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;
/**
* The drupal finder service.
*
* @var \DrupalFinder\DrupalFinder
*/
protected $drupalFinder;
/**
* ModifiedCode constructor.
*
* @param \Psr\Log\LoggerInterface $logger
* The logger.
* @param \DrupalFinder\DrupalFinder $drupal_finder
* The Drupal finder.
* @param \GuzzleHttp\ClientInterface $http_client
* The HTTP client.
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
*/
public function __construct(LoggerInterface $logger, DrupalFinder $drupal_finder, ClientInterface $http_client, ConfigFactoryInterface $config_factory) {
$this->logger = $logger;
$this->drupalFinder = $drupal_finder;
$this->httpClient = $http_client;
$this->configFactory = $config_factory;
$this->drupalFinder->locateRoot(getcwd());
}
/**
* {@inheritdoc}
*/
public function getModifiedFiles(array $extensions = []) {
$modified_files = [];
$promises = $this->getHashRequests($extensions);
// Wait until all the requests are finished.
(new EachPromise($promises, [
'concurrency' => 4,
'fulfilled' => function ($resource) use (&$modified_files) {
$this->processHashes($resource, $modified_files);
},
]))->promise()->wait();
return $modified_files;
}
/**
* Process checking hashes of files from external URL.
*
* @param resource $resource
* A resource handle.
* @param array $modified_files
* The list of modified files.
*/
protected function processHashes($resource, array &$modified_files) {
while (($line = fgets($resource)) !== FALSE) {
list($hash, $file) = preg_split('/\s+/', $line, 2);
$file = trim($file);
// If the line is empty, proceed to the next line.
if (empty($hash) && empty($file)) {
continue;
}
// If one of the values is invalid, log and continue.
if (!$hash || !$file) {
$this->logger->error('@hash or @file is empty; the hash file is malformed for this line.', ['@hash' => $hash, '@file' => $file]);
continue;
}
$file_path = $this->drupalFinder->getDrupalRoot() . DIRECTORY_SEPARATOR . $file;
if (!file_exists($file_path) || hash_file('sha512', $file_path) !== $hash) {
$modified_files[] = $file_path;
}
}
if (!feof($resource)) {
$this->logger->error('Stream for resource closed prematurely.');
}
fclose($resource);
}
/**
* Get an iterator of promises that return a resource stream.
*
* @param array $extensions
* The list of extensions, keyed by extension name and value the info array.
*
* @codingStandardsIgnoreStart
*
* @return \Generator
*
* @@codingStandardsIgnoreEnd
*/
protected function getHashRequests(array $extensions) {
foreach ($extensions as $extension_name => $info) {
$url = $this->buildUrl($extension_name, $info);
yield $this->getPromise($url);
}
}
/**
* Get a promise.
*
* @param string $url
* The URL.
*
* @return \GuzzleHttp\Promise\PromiseInterface
* The promise.
*/
protected function getPromise($url) {
return $this->httpClient->requestAsync('GET', $url, [
'stream' => TRUE,
'read_timeout' => 30,
])
->then(function (ResponseInterface $response) {
return $response->getBody()->detach();
});
}
/**
* Build an extension's hash file URL.
*
* @param string $extension_name
* The extension name.
* @param array $info
* The extension's info.
*
* @return string
* The URL endpoint with for an extension.
*/
protected function buildUrl($extension_name, array $info) {
$version = $this->getExtensionVersion($extension_name, $info);
$project_name = $this->getProjectName($extension_name, $info);
$hash_name = $this->getHashName($info);
$uri = ltrim($this->configFactory->get('automatic_updates.settings')->get('download_uri'), '/');
return Url::fromUri($uri . "/$project_name/$version/$hash_name")->toString();
}
/**
* Get the extension version.
*
* @param string $extension_name
* The extension name.
* @param array $info
* The extension's info.
*
* @return string|null
* The version or NULL if undefined.
*/
protected function getExtensionVersion($extension_name, array $info) {
$version = isset($info['version']) ? $info['version'] : NULL;
// TODO: consider using ocramius/package-versions to discover the installed
// version from composer.lock.
// See https://www.drupal.org/project/automatic_updates/issues/3054002
return $version;
}
/**
* Get the extension's project name.
*
* @param string $extension_name
* The extension name.
* @param array $info
* The extension's info.
*
* @return string
* The project name or fallback to extension name if project is undefined.
*/
protected function getProjectName($extension_name, array $info) {
$project_name = isset($info['project']) ? $info['project'] : $extension_name;
// TODO: parse the composer.json for the name if it isn't set in info.
// See https://www.drupal.org/project/automatic_updates/issues/3054002.
return $project_name;
}
/**
* Get the hash file name.
*
* @param array $info
* The extension's info.
*
* @return string|null
* The hash name.
*/
protected function getHashName(array $info) {
$hash_name = 'SHA512SUMS';
if (isset($info['project'])) {
$hash_name .= '-package';
}
return $hash_name;
}
}
<?php
namespace Drupal\automatic_updates\Services;
/**
* Modified files service interface.
*/
interface ModifiedFilesInterface {
/**
* Get list of modified files.
*
* @param array $extensions
* The list of extensions, keyed by extension name with values an info
* array.
*
* @return array
* The modified files.
*/
public function getModifiedFiles(array $extensions = []);
}
<?php
namespace Drupal\test_automatic_updates\Controller;
use Drupal\Core\Controller\ControllerBase;
use Symfony\Component\HttpFoundation\Response;
/**
* Class HashesController.
*/
class HashesController extends ControllerBase {
/**
* Test hashes controller.
*
* @param string $extension
* The extension name.
* @param string $version
* The version string.
*
* @return \Symfony\Component\HttpFoundation\Response
* A file with hashes.
*/
public function hashes($extension, $version) {
$response = Response::create();
$response->headers->set('Content-Type', 'text/plain');
if ($extension === 'core' && $version === '8.7.0') {
$response->setContent("2cedbfcde76961b1f65536e3c69e13d8ad850619235f4aa2752ae66fe5e5a2d928578279338f099b5318d92c410040e995cb62ba1cc4512ec17cf21715c760a2 core/LICENSE.txt\n");
}
elseif ($extension === 'core' && $version === '8.0.0') {
// Fake out a change in the LICENSE.txt.
$response->setContent("2d4ce6b272311ca4159056fb75138eba1814b65323c35ae5e0978233918e45e62bb32fdd2e0e8f657954fd5823c045762b3b59645daf83246d88d8797726e02c core/LICENSE.txt\n");
}
return $response;
}
}
......@@ -12,3 +12,10 @@ test_automatic_updates.json_test_denied_controller:
_title: 'JSON'
requirements:
_access: 'FALSE'
test_automatic_updates.hashes_endpoint:
path: '/automatic_updates/{extension}/{version}/SHA512SUMS'
defaults:
_controller: '\Drupal\test_automatic_updates\Controller\HashesController::hashes'
_title: 'SHA512SUMS'
requirements:
_access: 'TRUE'
......@@ -92,9 +92,11 @@ class AutomaticUpdatesTest extends BrowserTestBase {
*/
public function testReadinessChecks() {
// Test manually running readiness checks.
$url = $this->buildUrl('<front>') . '/automatic_updates';
$this->config('automatic_updates.settings')->set('download_uri', $url);
$this->drupalGet(Url::fromRoute('automatic_updates.settings'));
$this->clickLink('run the readiness checks');
$this->assertSession()->pageTextContains('Your site does not pass some readiness checks for automatic updates. It might not be completely eligible for automatic updates.');
$this->assertSession()->pageTextContains('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might effect the eligibility for automatic updates.');
}
}
<?php
namespace Drupal\Tests\automatic_updates\Functional;
use Drupal\automatic_updates\Services\ModifiedFiles;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
/**
* Tests of automatic updates.
*
* @group automatic_updates
*/
class ModifiedFilesTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'automatic_updates',
'test_automatic_updates',
];
/**
* Tests modified files service.
*/
public function testModifiedFiles() {
// No modified code.
$modified_files = new TestModifiedFiles(
$this->container->get('logger.channel.automatic_updates'),
$this->container->get('automatic_updates.drupal_finder'),
$this->container->get('http_client'),
$this->container->get('config.factory')
);
$this->initHashesEndpoint($modified_files, 'core', '8.7.0');
$files = $modified_files->getModifiedFiles(['system' => []]);
$this->assertEmpty($files);
// Hash doesn't match i.e. modified code, including contrib logic.
$this->initHashesEndpoint($modified_files, 'core', '8.0.0');
$files = $modified_files->getModifiedFiles(['system' => []]);
$this->assertCount(1, $files);
$this->assertStringEndsWith('core/LICENSE.txt', $files[0]);
}
/**
* Set the hashes endpoint.
*
* @param TestModifiedFiles $modified_code
* The modified code object.
* @param string $extension
* The extension name.
* @param string $version
* The version.
*/
protected function initHashesEndpoint(TestModifiedFiles $modified_code, $extension, $version) {
$modified_code->endpoint = $this->buildUrl(Url::fromRoute('test_automatic_updates.hashes_endpoint', [
'extension' => $extension,
'version' => $version,
]));
}
}
/**
* Class TestModifiedCode.
*/
class TestModifiedFiles extends ModifiedFiles {
/**
* The endpoint url.
*
* @var string
*/
public $endpoint;
/**
* {@inheritdoc}
*/
protected function buildUrl($extension_name, array $info) {
return $this->endpoint;
}
}
<?php
namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
use Drupal\automatic_updates\ReadinessChecker\ModifiedCode;
use Drupal\KernelTests\KernelTestBase;
/**
* Tests modified code readiness checking.
*
* @group automatic_updates
*/
class ModifiedCodeTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = [
'automatic_updates',
];
/**
* Tests the functionality of modified code readiness checks.
*/
public function testModifiedCode() {
// No modified code.
$modified_code = new ModifiedCode($this->container->get('logger.channel.automatic_updates'), $this->container->get('automatic_updates.drupal_finder'));
$messages = $modified_code->run();
$this->assertEmpty($messages);
}
}
<?php
namespace Drupal\Tests\automatic_updates\Kernel;
use Drupal\automatic_updates\ReadinessChecker\ModifiedFiles;
use Drupal\automatic_updates\Services\ModifiedFilesInterface;
use Drupal\KernelTests\KernelTestBase;
use Prophecy\Argument;
/**
* Tests of automatic updates.
*
* @group automatic_updates
*/
class ModifiedFilesTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'automatic_updates',
'test_automatic_updates',
];
/**
* Tests modified files service.
*/
public function testModifiedFiles() {
/** @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\automatic_updates\Services\ModifiedFilesInterface $service */
$service = $this->prophesize(ModifiedFilesInterface::class);
$service->getModifiedFiles(Argument::type('array'))->willReturn([]);
$modules = $this->container->get('extension.list.module');
$profiles = $this->container->get('extension.list.profile');
$themes = $this->container->get('extension.list.theme');
// No modified code.
$modified_files = new ModifiedFiles(
$service->reveal(),
$modules,
$profiles,
$themes
);
$messages = $modified_files->run();
$this->assertEmpty($messages);
// Hash doesn't match i.e. modified code.
$service->getModifiedFiles(Argument::type('array'))->willReturn(['core/LICENSE.txt']);
$messages = $modified_files->run();
$this->assertCount(1, $messages);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment