vendor = $vendor; } /** * Analyse differences between two versions. */ public function analyze(string $previousVersion, string $currentVersion): VersionRelease { $this->previousPath = $this->vendor->getStoragePath($previousVersion); $this->currentPath = $this->vendor->getStoragePath($currentVersion); // Create version release record $release = VersionRelease::create([ 'vendor_id' => $this->vendor->id, 'version' => $currentVersion, 'previous_version' => $previousVersion, 'storage_path' => $this->currentPath, ]); AnalysisLog::logAnalysisStarted($release); try { // Get all file changes $changes = $this->getFileChanges(); // Cache the diffs $stats = $this->cacheDiffs($release, $changes); // Update release with stats $release->update([ 'files_added' => $stats['added'], 'files_modified' => $stats['modified'], 'files_removed' => $stats['removed'], 'analyzed_at' => now(), ]); AnalysisLog::logAnalysisCompleted($release, $stats); return $release; } catch (\Exception $e) { AnalysisLog::logAnalysisFailed($release, $e->getMessage()); throw $e; } } /** * Get all file changes between versions using diff. */ protected function getFileChanges(): array { $changes = [ 'added' => [], 'modified' => [], 'removed' => [], ]; // Get list of all files in both versions $previousFiles = $this->getFileList($this->previousPath); $currentFiles = $this->getFileList($this->currentPath); // Find added files $addedFiles = array_diff($currentFiles, $previousFiles); foreach ($addedFiles as $file) { if (! $this->shouldIgnore($file)) { $changes['added'][] = $file; } } // Find removed files $removedFiles = array_diff($previousFiles, $currentFiles); foreach ($removedFiles as $file) { if (! $this->shouldIgnore($file)) { $changes['removed'][] = $file; } } // Find modified files $commonFiles = array_intersect($previousFiles, $currentFiles); foreach ($commonFiles as $file) { if ($this->shouldIgnore($file)) { continue; } $prevPath = $this->previousPath.'/'.$file; $currPath = $this->currentPath.'/'.$file; if ($this->filesAreDifferent($prevPath, $currPath)) { $changes['modified'][] = $file; } } return $changes; } /** * Get list of all files in a directory recursively. */ protected function getFileList(string $basePath): array { if (! File::isDirectory($basePath)) { return []; } $files = []; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if ($file->isFile()) { $relativePath = str_replace($basePath.'/', '', $file->getPathname()); $files[] = $relativePath; } } return $files; } /** * Check if a file should be ignored. */ protected function shouldIgnore(string $path): bool { return $this->vendor->shouldIgnorePath($path); } /** * Check if two files are different. */ protected function filesAreDifferent(string $path1, string $path2): bool { if (! File::exists($path1) || ! File::exists($path2)) { return true; } // Quick hash comparison return md5_file($path1) !== md5_file($path2); } /** * Cache all diffs in the database. * * Uses a database transaction to ensure atomic operations - * if any diff fails to save, all changes are rolled back. */ protected function cacheDiffs(VersionRelease $release, array $changes): array { $stats = ['added' => 0, 'modified' => 0, 'removed' => 0]; DB::transaction(function () use ($release, $changes, &$stats) { // Cache added files foreach ($changes['added'] as $file) { $filePath = $this->currentPath.'/'.$file; $content = File::exists($filePath) ? File::get($filePath) : null; DiffCache::create([ 'version_release_id' => $release->id, 'file_path' => $file, 'change_type' => DiffCache::CHANGE_ADDED, 'new_content' => $content, 'category' => DiffCache::detectCategory($file), ]); $stats['added']++; } // Cache modified files with diff foreach ($changes['modified'] as $file) { $diff = $this->generateDiff($file); DiffCache::create([ 'version_release_id' => $release->id, 'file_path' => $file, 'change_type' => DiffCache::CHANGE_MODIFIED, 'diff_content' => $diff, 'category' => DiffCache::detectCategory($file), ]); $stats['modified']++; } // Cache removed files foreach ($changes['removed'] as $file) { DiffCache::create([ 'version_release_id' => $release->id, 'file_path' => $file, 'change_type' => DiffCache::CHANGE_REMOVED, 'category' => DiffCache::detectCategory($file), ]); $stats['removed']++; } }); return $stats; } /** * Validate that a path is safe and doesn't contain path traversal attempts. * * @throws InvalidArgumentException if path is invalid */ protected function validatePath(string $path, string $basePath): string { // Check for path traversal attempts if (str_contains($path, '..') || str_contains($path, "\0")) { Log::warning('Uptelligence: Path traversal attempt detected', [ 'path' => $path, 'basePath' => $basePath, ]); throw new InvalidArgumentException('Invalid path: path traversal not allowed'); } $fullPath = $basePath.'/'.$path; $realPath = realpath($fullPath); $realBasePath = realpath($basePath); // If path doesn't exist yet, validate the directory portion if ($realPath === false) { $dirPath = dirname($fullPath); $realDirPath = realpath($dirPath); if ($realDirPath === false || ! str_starts_with($realDirPath, $realBasePath)) { Log::warning('Uptelligence: Path escapes base directory', [ 'path' => $path, 'basePath' => $basePath, ]); throw new InvalidArgumentException('Invalid path: must be within base directory'); } return $fullPath; } // Ensure the real path is within the base path if (! str_starts_with($realPath, $realBasePath)) { Log::warning('Uptelligence: Path escapes base directory', [ 'path' => $path, 'realPath' => $realPath, 'basePath' => $basePath, ]); throw new InvalidArgumentException('Invalid path: must be within base directory'); } return $realPath; } /** * Generate diff for a file. * * Uses array-based Process invocation to prevent shell injection. * Validates paths to prevent path traversal attacks. */ protected function generateDiff(string $file): string { // Validate paths before using them $prevPath = $this->validatePath($file, $this->previousPath); $currPath = $this->validatePath($file, $this->currentPath); // Use array syntax to prevent shell injection - paths are passed as separate arguments // rather than being interpolated into a shell command string $result = Process::run(['diff', '-u', $prevPath, $currPath]); return $result->output(); } /** * Get priority files that changed. */ public function getPriorityChanges(VersionRelease $release): Collection { return $release->diffs() ->get() ->filter(fn ($diff) => $this->vendor->isPriorityPath($diff->file_path)); } /** * Get security-related changes. */ public function getSecurityChanges(VersionRelease $release): Collection { return $release->diffs() ->where('category', DiffCache::CATEGORY_SECURITY) ->get(); } /** * Generate summary statistics. */ public function getSummary(VersionRelease $release): array { $diffs = $release->diffs; return [ 'total_changes' => $diffs->count(), 'by_type' => [ 'added' => $diffs->where('change_type', DiffCache::CHANGE_ADDED)->count(), 'modified' => $diffs->where('change_type', DiffCache::CHANGE_MODIFIED)->count(), 'removed' => $diffs->where('change_type', DiffCache::CHANGE_REMOVED)->count(), ], 'by_category' => $diffs->groupBy('category')->map->count()->toArray(), 'priority_files' => $this->getPriorityChanges($release)->count(), 'security_files' => $this->getSecurityChanges($release)->count(), ]; } }