Files
Gnuboard7/public/install/includes/vendor-bundle-installer.php
T
HeuJung ffab451b4a fix(core,installer): dev vendor 설치본의 코어 업데이트 중단 수정 — stale 패키지 매니페스트 3계층
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.
2026-09-08 09:46:57 +09:00

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}' 실행 후 재시도하세요.";
}