Laravel PackageManifest 는 bootstrap/cache/packages.php 가 있으면 stale 여부를 검사하지 않고 그대로 읽어 등재된 provider 를 new 한다. 코어 업데이트는 Step 6/8 에서 vendor 를 --no-dev 로 교체하지만 그 파일은 Step 11 까지 이전 설치본의 것이 남으므로, 옵션 없는 `composer install` 로 깔린 사이트에서는 Step 10 spawn 자식이 새 vendor 에 없는 provider 를 찾다 부팅 단계에서 죽는다. 부팅 전이라 앱 로그에 흔적이 없고 부모에게는 자식의 비정상 종료로만 보여, 운영자에게는 「Class ... not found」 와 수동 재개 안내만 남는다. (sir.kr 커뮤니티 제보, 7.0.9 → 7.0.10) 3계층으로 막는다. 1. 부모 — spawn 직전 PackageManifestCacheHelper::clear 2. 자식 — bootstrap/app.php 가 G7_UPDATE_IN_PROGRESS 를 보고 스스로 정리한다. 이미 배포된 7.0.9·7.0.10 부모는 고칠 수 없으므로 그 아래에서 도는 신버전 자식의 유일한 방어다. App\ 클래스를 참조하지 않고 실패는 무시한다. 3. 범위 — CoreVersionChecker 의 env APP_VERSION 우선을 CoreUpdateContext 트리 안으로 축소한다. 업데이트 전에 뜬 artisan serve·큐 워커가 옛 값을 물고 확장을 incompatible_core 로 끄던 경로를 닫는다(관리자 템플릿이 대상이면 복구 UI 자체에 도달할 수 없다). 업데이트 트리 판정은 App\Support\CoreUpdateContext 가 단독 소유하고 CoreServiceProvider::isCoreUpdateInProgress 는 위임으로 남는다. bootstrap/app.php 의 복제본은 부팅 전이라 불가피한 예외이며, 두 조건의 동형성을 테스트가 단언한다. 실측 중 드러난 결함 2건을 함께 고쳤다. - ConfigCacheHelper::withPreservedContainer 가 파사드 애플리케이션을 되돌리지 않아 Step 11 이 `Target class [command.tinker] does not exist` 로 실패·롤백했다. - updateVersionInEnv 가 프로세스 환경을 갱신하지 않아 config 캐시에 이전 버전이 구워졌다 (Laravel env 저장소가 불변이라 재부팅으로도 덮이지 않는다). 인스톨러는 재사용 vendor 의 개발용 패키지를 installed.json 으로 감지해 설치 환경 확인 카드·설치 로그로 알리되 설치를 차단하지 않고( 결정 D1), 재사용 경로에서도 컴파일 캐시를 정리한다. 실행되는 명령만이 아니라 실패 시 안내하는 수동 명령까지 --no-dev 로 맞췄다. 코어 업데이트 완료·핸드오프·단독 재개 사후 단계에서 queue:restart 신호를 보낸다(D3). 코어 7.0.10 → 7.0.11.
402 lines
14 KiB
PHP
402 lines
14 KiB
PHP
<?php
|
|
|
|
use App\Support\ComposerInstallInfo;
|
|
|
|
/**
|
|
* Vendor 번들 설치 헬퍼 (웹 인스톨러 전용 shim).
|
|
*
|
|
* Laravel 부트 전 단계에서 실행되므로 App\Extension\Vendor\VendorBundleInstaller
|
|
* 클래스를 사용할 수 없다. 동일한 검증/추출 로직을 순수 PHP로 구현한다.
|
|
*
|
|
* Laravel 측 클래스와의 동등성은 VendorBundleInstallerShimTest 가 보장한다.
|
|
*/
|
|
const VENDOR_BUNDLE_SCHEMA_VERSION = '1.0';
|
|
const VENDOR_BUNDLE_ZIP_FILENAME = 'vendor-bundle.zip';
|
|
const VENDOR_BUNDLE_MANIFEST_FILENAME = 'vendor-bundle.json';
|
|
|
|
/**
|
|
* vendor-bundle.zip 의 무결성을 검증합니다.
|
|
*
|
|
* @param string $sourceDir vendor-bundle.zip 이 위치한 디렉토리
|
|
* @return array{valid: bool, errors: array<string>, meta: array<string, mixed>}
|
|
*/
|
|
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<string> 실제로 삭제된 파일명 목록 (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;
|
|
}
|
|
|
|
/**
|
|
* 이미 준비된 vendor 가 개발용(require-dev 포함) 설치인지 조사합니다.
|
|
*
|
|
* 판정은 코어와 공유하는 `App\Support\ComposerInstallInfo` 가 단독으로 소유한다 — 인스톨러와
|
|
* 코어 업데이트가 같은 vendor 를 두고 서로 다른 답을 내놓지 않도록.
|
|
*
|
|
* 클래스 파일 경로는 인자 `$basePath` 가 아니라 **인스톨러 자신의 트리** 기준으로 읽는다.
|
|
* 단위 테스트는 임시 디렉토리를 `$basePath` 로 넘기는데 그 경로에는 `app/Support` 가 없다.
|
|
*
|
|
* @param string $basePath 프로젝트 루트 (vendor 의 상위 디렉토리)
|
|
* @return array{dev: bool|null, packages: array<int, string>} dev 가 null 이면 판정 불가
|
|
*/
|
|
function detectDevVendorInstall(string $basePath): array
|
|
{
|
|
if (! class_exists('App\\Support\\ComposerInstallInfo')) {
|
|
$classFile = dirname(__DIR__, 3).'/app/Support/ComposerInstallInfo.php';
|
|
if (! is_file($classFile)) {
|
|
return ['dev' => null, 'packages' => []];
|
|
}
|
|
require_once $classFile;
|
|
}
|
|
|
|
return ComposerInstallInfo::inspect(rtrim($basePath, '/\\').'/vendor');
|
|
}
|
|
|
|
/**
|
|
* 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}' 실행 후 재시도하세요.";
|
|
}
|