Skip to content

Commit f2420a2

Browse files
M-arcuscoderabbitai[bot]shyim
authored
feat: add functionality for plugin checksums (#344)
* feat: add functionality for plugin checksums * fix: improve checks and use compatible function calls * fix: improve plugin directory handling and use native file hashing * fix: improve hashing and error messages * fix: improve hash service path handling, extension handling * fix: Improve messages if checksum file not found * fix: Improve error handling for plugin not found Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: fix previous commit * fix: Make context creation compatible with old Shopware 6 versions * fix: fix context creation and improve exception handling for administration * fix: indentation to 4 spaces * fix: code style fix * fix: remove file extensions * feat: improve checksum checks and paths * chore: Rename plugin to extension --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Shyim <[email protected]>
1 parent 8b0fc21 commit f2420a2

File tree

11 files changed

+673
-1
lines changed

11 files changed

+673
-1
lines changed
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Frosh\Tools\Command;
4+
5+
use Frosh\Tools\Components\ExtensionChecksum\ExtensionFileHashService;
6+
use Shopware\Core\Framework\Adapter\Console\ShopwareStyle;
7+
use Shopware\Core\Framework\Context;
8+
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
9+
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
10+
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
11+
use Shopware\Core\Framework\Plugin\PluginCollection;
12+
use Shopware\Core\Framework\Plugin\PluginEntity;
13+
use Symfony\Component\Console\Attribute\AsCommand;
14+
use Symfony\Component\Console\Command\Command;
15+
use Symfony\Component\Console\Input\InputArgument;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Output\OutputInterface;
18+
19+
#[AsCommand(
20+
name: 'frosh:extension:checksum:check',
21+
description: 'Checks the integrity of extension files',
22+
)]
23+
class ExtensionChecksumCheckCommand extends Command
24+
{
25+
/**
26+
* @param EntityRepository<PluginCollection> $pluginRepository
27+
*/
28+
public function __construct(
29+
private readonly EntityRepository $pluginRepository,
30+
private readonly ExtensionFileHashService $extensionFileHashService,
31+
) {
32+
parent::__construct();
33+
}
34+
35+
protected function configure(): void
36+
{
37+
$this->addArgument('extension', InputArgument::OPTIONAL, 'Extension name');
38+
}
39+
40+
/**
41+
* {@inheritdoc}
42+
*/
43+
protected function execute(InputInterface $input, OutputInterface $output): int
44+
{
45+
$io = new ShopwareStyle($input, $output);
46+
47+
$extensions = $this->getExtension((string) $input->getArgument('extension'), $io);
48+
if ($extensions->count() < 1) {
49+
$io->error('No extensions found');
50+
51+
return self::FAILURE;
52+
}
53+
54+
$io->info(\sprintf('Found %d extensions to check', $extensions->count()));
55+
56+
$success = true;
57+
foreach ($extensions as $extension) {
58+
$io->info('Checking extension: ' . $extension->getName());
59+
60+
$extensionChecksumCheckResult = $this->extensionFileHashService->checkExtensionForChanges($extension);
61+
if ($extensionChecksumCheckResult->isFileMissing()) {
62+
$io->warning(\sprintf('Checksum file for extension "%s" not found - integrity check skipped', $extension->getName()));
63+
64+
// Not setting $success to false because the creation of the checksum file is optional
65+
continue;
66+
}
67+
68+
// If the checksum file format changes: Add a check for $extensionChecksumResult->isWrongVersion() here
69+
// Right now the version is always 1.0.0
70+
71+
if ($extensionChecksumCheckResult->isWrongExtensionVersion()) {
72+
$io->error(\sprintf('Checksum file for extension "%s" was generated for a different extension version', $extension->getName()));
73+
$success = false;
74+
continue;
75+
}
76+
77+
if ($extensionChecksumCheckResult->isExtensionOk()) {
78+
$io->success(\sprintf('Extension "%s" has no detected file-changes.', $extension->getName()));
79+
80+
continue;
81+
}
82+
83+
$success = false;
84+
85+
$io->error(\sprintf('Extension "%s" has changed code.', $extension->getName()));
86+
$this->outputFileChanges($io, 'New files detected:', $extensionChecksumCheckResult->getNewFiles());
87+
$this->outputFileChanges($io, 'Changed files detected:', $extensionChecksumCheckResult->getChangedFiles());
88+
$this->outputFileChanges($io, 'Missing files detected:', $extensionChecksumCheckResult->getMissingFiles());
89+
}
90+
91+
return $success ? self::SUCCESS : self::FAILURE;
92+
}
93+
94+
private function getExtension(string $name, ShopwareStyle $io): PluginCollection
95+
{
96+
// @phpstan-ignore-next-line
97+
$context = method_exists(Context::class, 'createCLIContext') ? Context::createCLIContext() : Context::createDefaultContext();
98+
99+
if (!$name) {
100+
$io->info('Checking all extensions');
101+
102+
/** @var PluginCollection $extensions */
103+
$extensions = $this->pluginRepository->search(new Criteria(), $context)->getEntities();
104+
105+
return $extensions;
106+
}
107+
108+
$extensions = new PluginCollection();
109+
110+
$criteria = new Criteria();
111+
$criteria->addFilter(new EqualsFilter('name', $name));
112+
$extension = $this->pluginRepository->search($criteria, $context)->first();
113+
if ($extension instanceof PluginEntity) {
114+
$extensions->add($extension);
115+
} else {
116+
$io->error(\sprintf('Extension "%s" not found', $name));
117+
}
118+
119+
return $extensions;
120+
}
121+
122+
/**
123+
* @param string[] $files
124+
*/
125+
private function outputFileChanges(ShopwareStyle $io, string $text, array $files): void
126+
{
127+
if ($files) {
128+
$io->warning($text);
129+
$io->listing($files);
130+
}
131+
}
132+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Frosh\Tools\Command;
4+
5+
use Frosh\Tools\Components\ExtensionChecksum\ExtensionFileHashService;
6+
use Shopware\Core\Framework\Adapter\Console\ShopwareStyle;
7+
use Shopware\Core\Framework\Context;
8+
use Shopware\Core\Framework\DataAbstractionLayer\Entity;
9+
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
10+
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
11+
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
12+
use Shopware\Core\Framework\Plugin\PluginCollection;
13+
use Shopware\Core\Framework\Plugin\PluginEntity;
14+
use Symfony\Component\Console\Attribute\AsCommand;
15+
use Symfony\Component\Console\Command\Command;
16+
use Symfony\Component\Console\Input\InputArgument;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Output\OutputInterface;
19+
20+
#[AsCommand(
21+
name: 'frosh:extension:checksum:create',
22+
description: 'Creates a list of files and their checksums for an extension',
23+
)]
24+
class ExtensionChecksumCreateCommand extends Command
25+
{
26+
/**
27+
* @param EntityRepository<PluginCollection> $pluginRepository
28+
*/
29+
public function __construct(
30+
private readonly EntityRepository $pluginRepository,
31+
private readonly ExtensionFileHashService $extensionFileHashService,
32+
) {
33+
parent::__construct();
34+
}
35+
36+
protected function configure(): void
37+
{
38+
$this->addArgument('extension', InputArgument::OPTIONAL, 'Extension name');
39+
}
40+
41+
/**
42+
* {@inheritdoc}
43+
*/
44+
protected function execute(InputInterface $input, OutputInterface $output): int
45+
{
46+
$io = new ShopwareStyle($input, $output);
47+
// @phpstan-ignore-next-line
48+
$context = method_exists(Context::class, 'createCLIContext') ? Context::createCLIContext() : Context::createDefaultContext();
49+
50+
$extensionName = (string) $input->getArgument('extension');
51+
$extension = $this->getExtension($extensionName, $context);
52+
53+
if (!$extension instanceof PluginEntity) {
54+
$io->error(\sprintf('Extension "%s" not found', $extensionName));
55+
56+
return self::FAILURE;
57+
}
58+
59+
$checksumFilePath = $this->extensionFileHashService->getChecksumFilePathForExtension($extension);
60+
if (!$checksumFilePath) {
61+
$io->error(\sprintf('Extension "%s" checksum file path could not be identified', $extension->getName()));
62+
63+
return self::FAILURE;
64+
}
65+
66+
$checksumStruct = $this->extensionFileHashService->getChecksumData($extension);
67+
68+
$io->info(\sprintf('Writing %s checksums for extension "%s" to file %s', \count($checksumStruct->getHashes()), $extension->getName(), $checksumFilePath));
69+
70+
$directory = \dirname($checksumFilePath);
71+
if (!is_dir($directory)) {
72+
$io->error(\sprintf('Directory "%s" does not exist or cannot be read', $directory));
73+
74+
return self::FAILURE;
75+
}
76+
77+
if (!is_writable($directory)) {
78+
$io->error(\sprintf('Directory "%s" is not writable', $directory));
79+
80+
return self::FAILURE;
81+
}
82+
83+
if (file_put_contents($checksumFilePath, \json_encode($checksumStruct->jsonSerialize(), \JSON_THROW_ON_ERROR)) === false) {
84+
$io->error(\sprintf('Failed to write to file "%s"', $checksumFilePath));
85+
86+
return self::FAILURE;
87+
}
88+
89+
return self::SUCCESS;
90+
}
91+
92+
private function getExtension(string $name, Context $context): ?Entity
93+
{
94+
$criteria = new Criteria();
95+
$criteria->addFilter(new EqualsFilter('name', $name));
96+
97+
return $this->pluginRepository->search($criteria, $context)->first();
98+
}
99+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Frosh\Tools\Components\ExtensionChecksum;
4+
5+
use Frosh\Tools\Components\ExtensionChecksum\Struct\ExtensionChecksumCheckResult;
6+
use Frosh\Tools\Components\ExtensionChecksum\Struct\ExtensionChecksumStruct;
7+
use Shopware\Core\Framework\Plugin\PluginEntity;
8+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
9+
use Symfony\Component\Finder\Finder;
10+
11+
class ExtensionFileHashService
12+
{
13+
/**
14+
* xxh128 is chosen for its excellent speed and collision resistance,
15+
* making it ideal for file integrity verification.
16+
*/
17+
private const HASH_ALGORITHM = 'xxh128';
18+
19+
private const CHECKSUM_FILE = 'checksum.json';
20+
21+
public function __construct(
22+
#[Autowire('%kernel.project_dir%')]
23+
private readonly string $rootDir,
24+
) {
25+
}
26+
27+
public function getChecksumFilePathForExtension(PluginEntity $extension): string
28+
{
29+
return $this->getExtensionRootPath($extension) . '/' . self::CHECKSUM_FILE;
30+
}
31+
32+
public function getChecksumData(PluginEntity $extension): ExtensionChecksumStruct
33+
{
34+
return ExtensionChecksumStruct::fromArray([
35+
'algorithm' => self::HASH_ALGORITHM,
36+
'hashes' => $this->getHashes($extension),
37+
'version' => ExtensionChecksumStruct::CURRENT_VERSION,
38+
'extensionVersion' => $extension->getVersion(),
39+
]);
40+
}
41+
42+
public function checkExtensionForChanges(PluginEntity $extension): ExtensionChecksumCheckResult
43+
{
44+
$checksumFilePath = $this->getChecksumFilePathForExtension($extension);
45+
if (!is_file($checksumFilePath)) {
46+
return new ExtensionChecksumCheckResult(fileMissing: true);
47+
}
48+
49+
if (!is_readable($checksumFilePath)) {
50+
throw new \RuntimeException(\sprintf('Checksum file "%s" exists but is not readable', $checksumFilePath));
51+
}
52+
53+
try {
54+
$checksumFileContent = json_decode(
55+
(string) file_get_contents($checksumFilePath),
56+
true,
57+
512,
58+
\JSON_THROW_ON_ERROR
59+
);
60+
} catch (\JsonException $exception) {
61+
throw new \RuntimeException(\sprintf('Checksum file "%s" is not valid JSON', $checksumFilePath), 0, $exception);
62+
}
63+
64+
$checksumFileData = ExtensionChecksumStruct::fromArray($checksumFileContent);
65+
66+
// If the checksum file format changes: Add a check for $checksumFileData->getVersion() here
67+
// Right now the version is always 1.0.0
68+
69+
if ($checksumFileData->getExtensionVersion() !== $extension->getVersion()) {
70+
return new ExtensionChecksumCheckResult(wrongExtensionVersion: true);
71+
}
72+
73+
$currentHashes = $this->getHashes($extension, $checksumFileData->getAlgorithm());
74+
$previouslyHashedFiles = $checksumFileData->getHashes();
75+
76+
$newFiles = array_diff_key($currentHashes, $previouslyHashedFiles);
77+
$missingFiles = array_diff_key($previouslyHashedFiles, $currentHashes);
78+
$changedFiles = [];
79+
foreach ($previouslyHashedFiles as $file => $oldHash) {
80+
if (isset($currentHashes[$file]) && $currentHashes[$file] !== $oldHash) {
81+
$changedFiles[] = $file;
82+
}
83+
}
84+
85+
return new ExtensionChecksumCheckResult(
86+
newFiles: array_keys($newFiles),
87+
changedFiles: $changedFiles,
88+
missingFiles: array_keys($missingFiles),
89+
);
90+
}
91+
92+
/**
93+
* @return array<string, string>
94+
*/
95+
private function getHashes(PluginEntity $extension, ?string $algorithm = null): array
96+
{
97+
$algorithm = $algorithm ?? self::HASH_ALGORITHM;
98+
99+
$extensionRootPath = $this->getExtensionRootPath($extension);
100+
101+
$finder = new Finder();
102+
$finder->in([$extensionRootPath])
103+
->files()
104+
->ignoreDotFiles(false)
105+
->notPath(self::CHECKSUM_FILE)
106+
->notPath('Resources/public/administration')
107+
->notPath('/vendor/')
108+
->notPath('/node_modules/');
109+
110+
$hashes = [];
111+
foreach ($finder as $file) {
112+
$absoluteFilePath = $file->getRealPath();
113+
if (!\is_string($absoluteFilePath) || !$absoluteFilePath) {
114+
continue;
115+
}
116+
117+
$hash = \hash_file($algorithm, $absoluteFilePath);
118+
if ($hash === false) {
119+
throw new \RuntimeException(\sprintf(
120+
'Could not generate %s hash for "%s"',
121+
$algorithm,
122+
$absoluteFilePath
123+
));
124+
}
125+
126+
// Make sure the replacement handles Windows and Unix paths
127+
$relativePath = \ltrim(str_replace([$extensionRootPath, '\\'], ['', '/'], $absoluteFilePath), '/');
128+
129+
$hashes[$relativePath] = $hash;
130+
}
131+
132+
ksort($hashes);
133+
134+
return $hashes;
135+
}
136+
137+
private function getExtensionRootPath(PluginEntity $extension): string
138+
{
139+
return \rtrim($this->rootDir . '/' . $extension->getPath(), '/\\');
140+
}
141+
}

0 commit comments

Comments
 (0)