#!/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());