From 442cc2cc7755d131aa384c1b471a86bcd2a153bc Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 19 May 2026 20:47:19 +0000 Subject: [PATCH] feat: add check_file_integrity.php Authored-by: Moko Consulting --- validate/check_file_integrity.php | 585 ++++++++++++++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 validate/check_file_integrity.php diff --git a/validate/check_file_integrity.php b/validate/check_file_integrity.php new file mode 100644 index 0000000..97edfde --- /dev/null +++ b/validate/check_file_integrity.php @@ -0,0 +1,585 @@ +#!/usr/bin/env php + + * + * 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 [options]'); + $this->log(''); + $this->log('Options:'); + $this->log(' --config SFTP config JSON (host, port, user, identity)'); + $this->log(' --repo-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());