, meta: array} */ function verifyVendorBundle(string $sourceDir): array { $zipPath = $sourceDir.DIRECTORY_SEPARATOR.VENDOR_BUNDLE_ZIP_FILENAME; $manifestPath = $sourceDir.DIRECTORY_SEPARATOR.VENDOR_BUNDLE_MANIFEST_FILENAME; if (! file_exists($zipPath)) { return ['valid' => false, 'errors' => ['bundle_zip_missing'], 'meta' => []]; } if (! file_exists($manifestPath)) { return ['valid' => false, 'errors' => ['bundle_manifest_missing'], 'meta' => []]; } $manifestJson = @file_get_contents($manifestPath); if ($manifestJson === false) { return ['valid' => false, 'errors' => ['bundle_manifest_invalid'], 'meta' => []]; } $manifest = json_decode($manifestJson, true); if (! is_array($manifest)) { return ['valid' => false, 'errors' => ['bundle_manifest_invalid'], 'meta' => []]; } $schemaVersion = $manifest['schema_version'] ?? null; if ($schemaVersion !== VENDOR_BUNDLE_SCHEMA_VERSION) { return ['valid' => false, 'errors' => ['bundle_schema_unsupported'], 'meta' => $manifest]; } $errors = []; $expectedZipHash = $manifest['zip_sha256'] ?? null; if ($expectedZipHash === null) { $errors[] = 'bundle_manifest_invalid'; } else { $actualZipHash = hash_file('sha256', $zipPath); if (! hash_equals((string) $expectedZipHash, (string) $actualZipHash)) { $errors[] = 'zip_hash_mismatch'; } } $composerJsonPath = $sourceDir.DIRECTORY_SEPARATOR.'composer.json'; if (file_exists($composerJsonPath)) { $expected = $manifest['composer_json_sha256'] ?? null; if ($expected !== null) { $actual = hash_file('sha256', $composerJsonPath); if (! hash_equals((string) $expected, (string) $actual)) { $errors[] = 'composer_json_sha_mismatch'; } } } if (! empty($errors)) { return ['valid' => false, 'errors' => $errors, 'meta' => $manifest]; } return ['valid' => true, 'errors' => [], 'meta' => $manifest]; } /** * vendor-bundle.zip 을 대상 디렉토리에 추출합니다. * * 공유 호스팅 친화적 설계: * - targetDir 자체(프로젝트 루트)의 쓰기 권한은 요구하지 않음 * - vendor/ 디렉토리 자체는 유지하고 내부 항목만 조작 * - 기존 내용은 vendor/.bundle_backup_{ts}/ 로 이동 (vendor/ 쓰기 권한만으로 가능) * - 추출 실패 시 백업에서 복원, 성공 시 백업 삭제 * * @param string $sourceDir vendor-bundle.zip 위치 * @param string $targetDir vendor/ 가 배치될 위치 * @return array{success: bool, error?: string, package_count?: int} */ function extractVendorBundle(string $sourceDir, string $targetDir): array { if (! class_exists('ZipArchive')) { return ['success' => false, 'error' => 'zip_archive_not_available']; } $integrity = verifyVendorBundle($sourceDir); if (! $integrity['valid']) { return [ 'success' => false, 'error' => 'bundle_integrity_failed: '.implode(', ', $integrity['errors']), ]; } $zipPath = $sourceDir.DIRECTORY_SEPARATOR.VENDOR_BUNDLE_ZIP_FILENAME; $vendorDir = $targetDir.DIRECTORY_SEPARATOR.'vendor'; $backupDirName = '.bundle_backup_'.date('Ymd_His'); $backupDir = $vendorDir.DIRECTORY_SEPARATOR.$backupDirName; // 쓰기 권한 유연 검사 — vendor/ 가 있으면 vendor/ 권한만, 없으면 targetDir 권한 필요 if (is_dir($vendorDir)) { if (! is_writable($vendorDir)) { return ['success' => false, 'error' => 'target_not_writable: '.$vendorDir.buildVendorBundlePermissionHint($vendorDir)]; } } else { if (! is_dir($targetDir) || ! is_writable($targetDir)) { return ['success' => false, 'error' => 'target_not_writable: '.$targetDir.buildVendorBundlePermissionHint($targetDir)]; } } // zip slip 사전 검증 $unsafePathError = checkVendorBundleZipSafety($zipPath); if ($unsafePathError !== null) { return ['success' => false, 'error' => 'bundle_contains_unsafe_path: '.$unsafePathError]; } // 기존 vendor/ 내부 항목을 vendor/.bundle_backup_{ts}/ 로 이동 // (vendor/ 디렉토리 자체는 유지 — targetDir 쓰기 권한 불필요) $hasBackup = false; if (is_dir($vendorDir)) { $hasBackup = moveVendorContentsToBackup($vendorDir, $backupDir, $backupDirName); } else { // vendor/ 없음 — 신규 생성 (targetDir 쓰기 권한 필요, 위에서 이미 검증) if (! @mkdir($vendorDir, 0755, true) && ! is_dir($vendorDir)) { return ['success' => false, 'error' => 'target_not_writable: '.$vendorDir]; } } $zip = new ZipArchive; $openResult = $zip->open($zipPath); if ($openResult !== true) { if ($hasBackup && is_dir($backupDir)) { restoreVendorFromBackup($vendorDir, $backupDir, $backupDirName); } return ['success' => false, 'error' => 'extraction_failed: ZipArchive::open failed (code '.$openResult.')']; } $extracted = $zip->extractTo($targetDir); $zip->close(); if (! $extracted) { if ($hasBackup && is_dir($backupDir)) { restoreVendorFromBackup($vendorDir, $backupDir, $backupDirName); } return ['success' => false, 'error' => 'extraction_failed: ZipArchive::extractTo returned false']; } // 성공: 백업 디렉토리 삭제 (vendor/.bundle_backup_{ts}/) if ($hasBackup && is_dir($backupDir)) { deleteVendorBundleDirectory($backupDir); } return [ 'success' => true, 'package_count' => (int) ($integrity['meta']['package_count'] ?? 0), ]; } /** * 기존 vendor/ 내부 항목을 vendor/.bundle_backup_{ts}/ 로 이동합니다. * * vendor/ 디렉토리 자체는 유지하므로 targetDir 쓰기 권한이 필요하지 않습니다. * * @return bool 백업 성공 여부 (false 시 in-place overwrite 로 추출 진행) */ function moveVendorContentsToBackup(string $vendorDir, string $backupDir, string $backupDirName): bool { if (! @mkdir($backupDir) && ! is_dir($backupDir)) { return false; } $items = @scandir($vendorDir) ?: []; foreach ($items as $item) { if ($item === '.' || $item === '..' || $item === $backupDirName) { continue; } $src = $vendorDir.DIRECTORY_SEPARATOR.$item; $dst = $backupDir.DIRECTORY_SEPARATOR.$item; @rename($src, $dst); } return true; } /** * 백업 디렉토리에서 vendor/ 로 내용을 복원합니다. */ function restoreVendorFromBackup(string $vendorDir, string $backupDir, string $backupDirName): void { // 추출로 이미 생성된 파일/디렉토리 정리 (백업 폴더 제외) $items = @scandir($vendorDir) ?: []; foreach ($items as $item) { if ($item === '.' || $item === '..' || $item === $backupDirName) { continue; } $path = $vendorDir.DIRECTORY_SEPARATOR.$item; if (is_dir($path) && ! is_link($path)) { deleteVendorBundleDirectory($path); } elseif (file_exists($path)) { @unlink($path); } } // 백업 항목을 원위치로 이동 $backupItems = @scandir($backupDir) ?: []; foreach ($backupItems as $item) { if ($item === '.' || $item === '..') { continue; } $src = $backupDir.DIRECTORY_SEPARATOR.$item; $dst = $vendorDir.DIRECTORY_SEPARATOR.$item; @rename($src, $dst); } @rmdir($backupDir); } /** * zip 내부 파일 경로의 안전성을 검증합니다 (zip slip 방지). */ function checkVendorBundleZipSafety(string $zipPath): ?string { $zip = new ZipArchive; if ($zip->open($zipPath) !== true) { return 'cannot open zip'; } for ($i = 0; $i < $zip->numFiles; $i++) { $name = $zip->getNameIndex($i); if ($name === false) { continue; } $normalized = str_replace('\\', '/', $name); if ( str_contains($normalized, '../') || str_starts_with($normalized, '/') || preg_match('#^[A-Za-z]:/#', $normalized) ) { $zip->close(); return $name; } } $zip->close(); return null; } /** * 디렉토리 재귀 삭제 (Laravel File 파사드 의존 제거). */ function deleteVendorBundleDirectory(string $dir): bool { if (! is_dir($dir)) { return false; } $items = scandir($dir); foreach ($items as $item) { if ($item === '.' || $item === '..') { continue; } $path = $dir.DIRECTORY_SEPARATOR.$item; if (is_dir($path)) { deleteVendorBundleDirectory($path); } else { @unlink($path); } } return @rmdir($dir); } /** * Laravel 부트 시 재생성되는 컴파일 캐시(packages.php / services.php / config.php)를 제거합니다. * * 인스톨러는 vendor 를 교체하기 때문에 이전 환경(특히 dev) 에서 생성된 캐시가 남아있으면 * 제거된 패키지의 ServiceProvider 를 참조하다가 "Class ... not found" 오류가 발생한다. * 본 함수는 Laravel\Foundation\ComposerScripts::clearCompiled() 와 동일한 3개 파일을 정리한다. * * @param string $basePath 프로젝트 루트 (bootstrap/cache 의 상위 디렉토리) * @return array 실제로 삭제된 파일명 목록 (basename) */ function clearLaravelCompiledCache(string $basePath): array { $cacheDir = $basePath.DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'cache'; if (! is_dir($cacheDir)) { return []; } $cleared = []; foreach (['packages.php', 'services.php', 'config.php'] as $filename) { $path = $cacheDir.DIRECTORY_SEPARATOR.$filename; if (is_file($path) && @unlink($path)) { $cleared[] = $filename; } } return $cleared; } /** * Composer 실행이 현재 환경에서 가능한지 검사합니다. * * proc_open() 사용 가능 여부 + composer 바이너리 발견 여부를 종합 판단. */ function canExecuteComposerForInstall(?string $composerBinary, ?string $phpBinary): bool { // proc_open 사용 가능 여부 if (! function_exists('proc_open')) { return false; } $disabled = array_map('trim', explode(',', (string) ini_get('disable_functions'))); if (in_array('proc_open', $disabled, true)) { return false; } // composer 바이너리 결정 $binary = $composerBinary; if (! $binary) { // PATH 검색 단계는 인스톨러 환경에서 비용이 크므로 단순화 $binary = 'composer'; } return ! empty($binary); } /** * 권한 거부 시 진단 힌트를 생성합니다. * * 인스톨러는 Laravel __() 사용 불가하므로 순수 PHP로 메시지를 구성합니다. * (한국어 고정 — 인스톨러는 다국어 지원 범위가 제한적) */ function buildVendorBundlePermissionHint(string $path): string { if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) { return ''; } $currentUid = posix_geteuid(); $currentUser = posix_getpwuid($currentUid)['name'] ?? (string) $currentUid; if (! file_exists($path)) { return " (실행 사용자: {$currentUser})"; } $ownerUid = fileowner($path); if ($currentUid === $ownerUid) { return " (실행 사용자: {$currentUser}, 소유자 동일)"; } $ownerUser = posix_getpwuid($ownerUid)['name'] ?? (string) $ownerUid; return " — 실행 사용자({$currentUser})와 디렉토리 소유자({$ownerUser})가 다릅니다. ". "FTP/SSH로 'chown -R {$currentUser} {$path}' 실행 후 재시도하세요."; }