feat: add check_file_integrity.php
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Universal: Cascade Main → Dev / Cascade main → branches (push) Has been cancelled
Generic: Repo Health / Access control (push) Has been cancelled
Generic: Repo Health / Release configuration (push) Has been cancelled
Generic: Repo Health / Scripts governance (push) Has been cancelled
Generic: Repo Health / Repository health (push) Has been cancelled
Authored-by: Moko Consulting
This commit is contained in:
@@ -0,0 +1,585 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
*
|
||||
* This file is part of a Moko Consulting project.
|
||||
*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*
|
||||
* FILE INFORMATION
|
||||
* DEFGROUP: MokoStandards.Scripts.Validate
|
||||
* INGROUP: MokoStandards
|
||||
* REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
|
||||
* PATH: /validate/check_file_integrity.php
|
||||
* VERSION: 01.00.00
|
||||
* BRIEF: Compare deployed files on a remote server against the local repository to detect drift
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
final class CheckFileIntegrity
|
||||
{
|
||||
private string $configFile = '';
|
||||
private string $repoPath = '';
|
||||
private bool $verbose = false;
|
||||
private bool $jsonOutput = false;
|
||||
|
||||
/** @var array{host: string, port: int, user: string, identity: string} */
|
||||
private array $sftpConfig = [];
|
||||
|
||||
public function run(): int
|
||||
{
|
||||
$this->parseArgs();
|
||||
|
||||
if ($this->configFile === '')
|
||||
{
|
||||
$this->log('ERROR: --config is required.');
|
||||
$this->printUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->repoPath === '')
|
||||
{
|
||||
$this->repoPath = getcwd() ?: '.';
|
||||
}
|
||||
|
||||
$this->repoPath = rtrim($this->repoPath, '/\\');
|
||||
|
||||
// Load SFTP config
|
||||
if (!$this->loadConfig())
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read manifest
|
||||
$manifest = $this->findManifest();
|
||||
|
||||
if ($manifest === null)
|
||||
{
|
||||
$this->log('ERROR: No Joomla XML manifest found in repo.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log("Manifest: {$manifest['file']}");
|
||||
$this->log("Extension type: {$manifest['type']}");
|
||||
$this->log("Extension name: {$manifest['name']}");
|
||||
|
||||
// Build deploy mappings
|
||||
$mappings = $this->buildDeployMappings($manifest);
|
||||
|
||||
if (count($mappings) === 0)
|
||||
{
|
||||
$this->log('ERROR: No deploy mappings could be determined from manifest.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($this->verbose)
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('Deploy mappings:');
|
||||
|
||||
foreach ($mappings as $mapping)
|
||||
{
|
||||
$this->log(" Local: {$mapping['local']} -> Remote: {$mapping['remote']}");
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
}
|
||||
|
||||
// Run rsync dry-run for each mapping
|
||||
$totalFiles = 0;
|
||||
$matchCount = 0;
|
||||
$differCount = 0;
|
||||
$serverOnly = [];
|
||||
$repoOnly = [];
|
||||
$differing = [];
|
||||
|
||||
foreach ($mappings as $mapping)
|
||||
{
|
||||
$localPath = $mapping['local'];
|
||||
$remotePath = $mapping['remote'];
|
||||
|
||||
if (!is_dir($localPath))
|
||||
{
|
||||
if ($this->verbose)
|
||||
{
|
||||
$this->log("SKIP: Local path does not exist: {$localPath}");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $this->rsyncDryRun($localPath, $remotePath);
|
||||
|
||||
if ($result === null)
|
||||
{
|
||||
$this->log("WARNING: rsync failed for mapping {$localPath} -> {$remotePath}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalFiles += $result['total'];
|
||||
$matchCount += $result['match'];
|
||||
$differCount += $result['differ'];
|
||||
$serverOnly = array_merge($serverOnly, $result['server_only']);
|
||||
$repoOnly = array_merge($repoOnly, $result['repo_only']);
|
||||
$differing = array_merge($differing, $result['differing']);
|
||||
}
|
||||
|
||||
// Output results
|
||||
$summary = [
|
||||
'total_files' => $totalFiles,
|
||||
'match' => $matchCount,
|
||||
'differ' => $differCount,
|
||||
'server_only' => count($serverOnly),
|
||||
'repo_only' => count($repoOnly),
|
||||
'details' => [
|
||||
'server_only_files' => $serverOnly,
|
||||
'repo_only_files' => $repoOnly,
|
||||
'differing_files' => $differing,
|
||||
],
|
||||
];
|
||||
|
||||
if ($this->jsonOutput)
|
||||
{
|
||||
fwrite(STDOUT, json_encode($summary, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||
}
|
||||
else
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('=== FILE INTEGRITY REPORT ===');
|
||||
$this->log('');
|
||||
$this->log(sprintf('Total files checked: %d', $totalFiles));
|
||||
$this->log(sprintf('Matching: %d', $matchCount));
|
||||
$this->log(sprintf('Differing: %d', $differCount));
|
||||
$this->log(sprintf('Server-only: %d', count($serverOnly)));
|
||||
$this->log(sprintf('Repo-only: %d', count($repoOnly)));
|
||||
|
||||
if ($this->verbose && count($differing) > 0)
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('Differing files:');
|
||||
|
||||
foreach ($differing as $f)
|
||||
{
|
||||
$this->log(" [CHANGED] {$f}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->verbose && count($serverOnly) > 0)
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('Server-only files (not in repo):');
|
||||
|
||||
foreach ($serverOnly as $f)
|
||||
{
|
||||
$this->log(" [SERVER] {$f}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->verbose && count($repoOnly) > 0)
|
||||
{
|
||||
$this->log('');
|
||||
$this->log('Repo-only files (not on server):');
|
||||
|
||||
foreach ($repoOnly as $f)
|
||||
{
|
||||
$this->log(" [REPO] {$f}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->log('');
|
||||
}
|
||||
|
||||
$hasDrift = $differCount > 0 || count($serverOnly) > 0 || count($repoOnly) > 0;
|
||||
|
||||
if ($hasDrift)
|
||||
{
|
||||
$this->log('RESULT: Drift detected.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$this->log('RESULT: Clean. No drift detected.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function parseArgs(): void
|
||||
{
|
||||
$args = $_SERVER['argv'] ?? [];
|
||||
$count = count($args);
|
||||
|
||||
for ($i = 1; $i < $count; $i++)
|
||||
{
|
||||
switch ($args[$i])
|
||||
{
|
||||
case '--config':
|
||||
$this->configFile = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--repo-path':
|
||||
$this->repoPath = $args[++$i] ?? '';
|
||||
break;
|
||||
case '--verbose':
|
||||
case '-v':
|
||||
$this->verbose = true;
|
||||
break;
|
||||
case '--json':
|
||||
$this->jsonOutput = true;
|
||||
break;
|
||||
case '--help':
|
||||
case '-h':
|
||||
$this->printUsage();
|
||||
exit(0);
|
||||
default:
|
||||
$this->log("WARNING: Unknown argument: {$args[$i]}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function printUsage(): void
|
||||
{
|
||||
$this->log('Usage: check_file_integrity.php --config <sftp-config.json> [options]');
|
||||
$this->log('');
|
||||
$this->log('Options:');
|
||||
$this->log(' --config <file> SFTP config JSON (host, port, user, identity)');
|
||||
$this->log(' --repo-path <path> Local repo path (default: current directory)');
|
||||
$this->log(' --verbose, -v Show detailed file-by-file output');
|
||||
$this->log(' --json Output results as JSON');
|
||||
$this->log(' --help, -h Show this help');
|
||||
}
|
||||
|
||||
private function loadConfig(): bool
|
||||
{
|
||||
if (!file_exists($this->configFile))
|
||||
{
|
||||
$this->log("ERROR: Config file not found: {$this->configFile}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = file_get_contents($this->configFile);
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (!is_array($data))
|
||||
{
|
||||
$this->log('ERROR: Config file is not valid JSON.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = $data['host'] ?? $data['sftp_host'] ?? '';
|
||||
$port = (int) ($data['port'] ?? $data['sftp_port'] ?? 22);
|
||||
$user = $data['user'] ?? $data['sftp_user'] ?? $data['username'] ?? '';
|
||||
$identity = $data['identity'] ?? $data['ssh_key_file'] ?? $data['key'] ?? '';
|
||||
|
||||
if ($host === '' || $user === '')
|
||||
{
|
||||
$this->log('ERROR: Config must contain at least "host" and "user".');
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->sftpConfig = [
|
||||
'host' => $host,
|
||||
'port' => $port,
|
||||
'user' => $user,
|
||||
'identity' => $identity,
|
||||
];
|
||||
|
||||
$this->log("Server: {$user}@{$host}:{$port}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function findManifest(): ?array
|
||||
{
|
||||
$srcDir = $this->repoPath . '/src';
|
||||
$searchDirs = is_dir($srcDir) ? [$srcDir] : [$this->repoPath];
|
||||
|
||||
foreach ($searchDirs as $dir)
|
||||
{
|
||||
$files = glob($dir . '/*.xml');
|
||||
|
||||
if ($files === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($files as $xmlFile)
|
||||
{
|
||||
$content = file_get_contents($xmlFile);
|
||||
|
||||
if ($content === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
libxml_use_internal_errors(true);
|
||||
$xml = simplexml_load_string($content);
|
||||
libxml_clear_errors();
|
||||
|
||||
if ($xml === false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$rootName = $xml->getName();
|
||||
|
||||
if ($rootName !== 'extension')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = (string) ($xml['type'] ?? '');
|
||||
$extName = (string) ($xml->name ?? basename($xmlFile, '.xml'));
|
||||
$element = (string) ($xml->element ?? $extName);
|
||||
|
||||
return [
|
||||
'file' => $xmlFile,
|
||||
'type' => $type,
|
||||
'name' => $extName,
|
||||
'element' => $element,
|
||||
'xml' => $xml,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function buildDeployMappings(array $manifest): array
|
||||
{
|
||||
$type = $manifest['type'];
|
||||
$element = strtolower($manifest['element']);
|
||||
$xml = $manifest['xml'];
|
||||
$srcDir = $this->repoPath . '/src';
|
||||
|
||||
if (!is_dir($srcDir))
|
||||
{
|
||||
$srcDir = $this->repoPath;
|
||||
}
|
||||
|
||||
$mappings = [];
|
||||
|
||||
switch ($type)
|
||||
{
|
||||
case 'template':
|
||||
$client = (string) ($xml['client'] ?? 'site');
|
||||
$basePath = $client === 'administrator'
|
||||
? '/administrator/templates/' . $element
|
||||
: '/templates/' . $element;
|
||||
|
||||
$mappings[] = [
|
||||
'local' => $srcDir,
|
||||
'remote' => $basePath,
|
||||
];
|
||||
break;
|
||||
|
||||
case 'component':
|
||||
$mappings[] = [
|
||||
'local' => $srcDir . '/admin',
|
||||
'remote' => '/administrator/components/' . $element,
|
||||
];
|
||||
$mappings[] = [
|
||||
'local' => $srcDir . '/site',
|
||||
'remote' => '/components/' . $element,
|
||||
];
|
||||
|
||||
if (is_dir($srcDir . '/media'))
|
||||
{
|
||||
$mappings[] = [
|
||||
'local' => $srcDir . '/media',
|
||||
'remote' => '/media/' . $element,
|
||||
];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'plugin':
|
||||
$group = (string) ($xml['group'] ?? 'system');
|
||||
$pluginName = str_replace('plg_' . $group . '_', '', $element);
|
||||
$mappings[] = [
|
||||
'local' => $srcDir,
|
||||
'remote' => '/plugins/' . $group . '/' . $pluginName,
|
||||
];
|
||||
break;
|
||||
|
||||
case 'module':
|
||||
$client = (string) ($xml['client'] ?? 'site');
|
||||
$basePath = $client === 'administrator'
|
||||
? '/administrator/modules/' . $element
|
||||
: '/modules/' . $element;
|
||||
|
||||
$mappings[] = [
|
||||
'local' => $srcDir,
|
||||
'remote' => $basePath,
|
||||
];
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic fallback: src -> extension root
|
||||
$mappings[] = [
|
||||
'local' => $srcDir,
|
||||
'remote' => '/templates/' . $element,
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
return $mappings;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total: int, match: int, differ: int, server_only: string[], repo_only: string[], differing: string[]}|null
|
||||
*/
|
||||
private function rsyncDryRun(string $localPath, string $remotePath): ?array
|
||||
{
|
||||
$localPath = rtrim($localPath, '/') . '/';
|
||||
$remotePath = rtrim($remotePath, '/') . '/';
|
||||
|
||||
$sshCmd = "ssh -p {$this->sftpConfig['port']}";
|
||||
|
||||
if ($this->sftpConfig['identity'] !== '')
|
||||
{
|
||||
$sshCmd .= ' -i ' . escapeshellarg($this->sftpConfig['identity']);
|
||||
}
|
||||
|
||||
$sshCmd .= ' -o StrictHostKeyChecking=no -o BatchMode=yes';
|
||||
|
||||
$remoteSpec = "{$this->sftpConfig['user']}@{$this->sftpConfig['host']}:{$remotePath}";
|
||||
|
||||
// Rsync from server to local (dry-run) to detect differences
|
||||
$cmd = sprintf(
|
||||
'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1',
|
||||
escapeshellarg($sshCmd),
|
||||
escapeshellarg($remoteSpec),
|
||||
escapeshellarg($localPath)
|
||||
);
|
||||
|
||||
if ($this->verbose)
|
||||
{
|
||||
$this->log("Running: {$cmd}");
|
||||
}
|
||||
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec($cmd, $output, $exitCode);
|
||||
|
||||
// Also run in reverse to find repo-only files
|
||||
$cmdReverse = sprintf(
|
||||
'rsync -avrc --dry-run --itemize-changes -e %s %s %s 2>&1',
|
||||
escapeshellarg($sshCmd),
|
||||
escapeshellarg($localPath),
|
||||
escapeshellarg($remoteSpec)
|
||||
);
|
||||
|
||||
$outputReverse = [];
|
||||
$exitCodeReverse = 0;
|
||||
exec($cmdReverse, $outputReverse, $exitCodeReverse);
|
||||
|
||||
// Parse itemize-changes output
|
||||
$serverOnly = [];
|
||||
$differing = [];
|
||||
$repoOnly = [];
|
||||
$totalTracked = 0;
|
||||
|
||||
foreach ($output as $line)
|
||||
{
|
||||
$line = trim($line);
|
||||
|
||||
// Itemize format: YXcstpoguax filename
|
||||
if (strlen($line) < 12 || $line[0] === ' ')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip summary lines
|
||||
if (preg_match('/^(sending|receiving|sent|total|$)/', $line))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = $matches[1];
|
||||
$filename = $matches[2];
|
||||
|
||||
// Skip directories
|
||||
if ($flags[1] === 'd')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalTracked++;
|
||||
|
||||
$updateType = $flags[0];
|
||||
|
||||
if ($updateType === '<' || $updateType === '>')
|
||||
{
|
||||
// File exists on source but differs or is new
|
||||
if ($flags[2] === '+')
|
||||
{
|
||||
// New file (only on server side for forward rsync)
|
||||
$serverOnly[] = $filename;
|
||||
}
|
||||
else
|
||||
{
|
||||
$differing[] = $filename;
|
||||
}
|
||||
}
|
||||
elseif ($updateType === 'c')
|
||||
{
|
||||
$differing[] = $filename;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse reverse output for repo-only files
|
||||
foreach ($outputReverse as $line)
|
||||
{
|
||||
$line = trim($line);
|
||||
|
||||
if (!preg_match('/^([<>ch.*][fdLDS][\.\+\?cstTpoguax]{9})\s+(.+)$/', $line, $matches))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
$flags = $matches[1];
|
||||
$filename = $matches[2];
|
||||
|
||||
if ($flags[1] === 'd')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($flags[2] === '+')
|
||||
{
|
||||
$repoOnly[] = $filename;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
$differing = array_unique($differing);
|
||||
$serverOnly = array_unique($serverOnly);
|
||||
$repoOnly = array_unique($repoOnly);
|
||||
|
||||
$differCount = count($differing);
|
||||
$serverOnlyCount = count($serverOnly);
|
||||
$repoOnlyCount = count($repoOnly);
|
||||
$matchCount = max(0, $totalTracked - $differCount - $serverOnlyCount);
|
||||
|
||||
return [
|
||||
'total' => $totalTracked,
|
||||
'match' => $matchCount,
|
||||
'differ' => $differCount,
|
||||
'server_only' => $serverOnly,
|
||||
'repo_only' => $repoOnly,
|
||||
'differing' => $differing,
|
||||
];
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
fwrite(STDERR, $message . PHP_EOL);
|
||||
}
|
||||
}
|
||||
|
||||
$app = new CheckFileIntegrity();
|
||||
exit($app->run());
|
||||
Reference in New Issue
Block a user