diff --git a/CHANGELOG.md b/CHANGELOG.md index 809f4247..0bba4494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixed - 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다. +- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.) ## [7.0.1] - 2026-07-03 diff --git a/public/install/api/state-management.php b/public/install/api/state-management.php index 23eac11a..b2115f57 100644 --- a/public/install/api/state-management.php +++ b/public/install/api/state-management.php @@ -138,7 +138,28 @@ class StateManagementApi ]; // JSON 응답 반환 - echo json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + // + // 최종 안전망 (gnuboard/g7#62): $response['logs'] 에 invalid UTF-8 바이트가 + // 섞이면 json_encode 가 false 를 반환하고 echo false = 빈 본문(HTTP 200) 이 되어 + // 프론트 폴링(res.json())이 "Unexpected end of JSON input" 으로 폭주한다. + // 로그는 addLog / task-runner 에서 이미 scrub 되지만, 어떤 경로로도 응답이 + // 빈 본문이 되지 않도록 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 false 를 가드한다. + $encoded = json_encode( + $response, + JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE + ); + + if ($encoded === false) { + // substitute 플래그로도 인코딩 실패한 극단적 케이스 — 폴링이 파싱 가능한 + // 최소 유효 JSON 을 반환해 프론트가 다음 tick 에서 정상 복구되도록 한다. + $encoded = json_encode([ + 'status' => $status, + 'logs' => [], + 'log_total' => $logTotal, + ], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + } + + echo $encoded; } /** diff --git a/public/install/includes/installer-state.php b/public/install/includes/installer-state.php index 20d8a6f0..1984b4e7 100644 --- a/public/install/includes/installer-state.php +++ b/public/install/includes/installer-state.php @@ -5,16 +5,15 @@ * * 설치 진행 상태를 storage/installer-state.json에 저장하고 조회합니다. */ - -if (!defined('BASE_PATH')) { +if (! defined('BASE_PATH')) { define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); // public/install/includes에서 프로젝트 루트로 } if (! defined('STATE_PATH')) { - define('STATE_PATH', BASE_PATH . '/storage/installer-state.json'); + define('STATE_PATH', BASE_PATH.'/storage/installer-state.json'); } if (! defined('INSTALLER_DIR')) { - define('INSTALLER_DIR', BASE_PATH . '/storage/installer'); + define('INSTALLER_DIR', BASE_PATH.'/storage/installer'); } /** @@ -36,15 +35,16 @@ function getInstallationState(): array // state.json 파일이 없으면 기본 상태 반환 // (설치 완료 후 워커가 DELETE_INSTALLER_AFTER_COMPLETE로 정상 삭제하는 경우가 있으므로 // 부재 자체는 에러가 아님. 호출자가 g7_installed 플래그 등으로 완료 여부 판정) - if (!file_exists(STATE_PATH)) { + if (! file_exists(STATE_PATH)) { return $defaultState; } // 파일 읽기 권한 체크 - if (!is_readable(STATE_PATH)) { - $msg = "[installer-state] State file is not readable: " . STATE_PATH; + if (! is_readable(STATE_PATH)) { + $msg = '[installer-state] State file is not readable: '.STATE_PATH; error_log($msg); addLog($msg); + return $defaultState; } @@ -53,9 +53,10 @@ function getInstallationState(): array // 파일 읽기 실패 시 기본 상태 반환 if ($content === false) { - $msg = "[installer-state] Failed to read state file: " . STATE_PATH; + $msg = '[installer-state] Failed to read state file: '.STATE_PATH; error_log($msg); addLog($msg); + return $defaultState; } @@ -65,9 +66,10 @@ function getInstallationState(): array if (json_last_error() !== JSON_ERROR_NONE) { $contentLen = strlen($content); $preview = substr($content, 0, 200); - $msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview; + $msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): ".json_last_error_msg().' / preview: '.$preview; error_log($msg); addLog($msg); + return $defaultState; } @@ -77,25 +79,27 @@ function getInstallationState(): array /** * 설치 상태 저장 * - * @param array $state 저장할 상태 배열 + * @param array $state 저장할 상태 배열 * @return bool 저장 성공 여부 */ function saveInstallationState(array $state): bool { // storage 디렉토리 존재 여부 확인 (생성하지 않음) - $storageDir = BASE_PATH . '/storage'; - if (!is_dir($storageDir)) { + $storageDir = BASE_PATH.'/storage'; + if (! is_dir($storageDir)) { $msg = "[installer-state] Storage directory does not exist: {$storageDir}"; error_log($msg); addLog($msg); + return false; } // 디렉토리 쓰기 권한 확인 - if (!is_writable($storageDir)) { + if (! is_writable($storageDir)) { $msg = "[installer-state] Storage directory is not writable: {$storageDir}"; error_log($msg); addLog($msg); + return false; } @@ -106,33 +110,36 @@ function saveInstallationState(array $state): bool $content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($content === false) { - $msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg(); + $msg = '[installer-state] Failed to encode state as JSON: '.json_last_error_msg(); error_log($msg); addLog($msg); + return false; } // 원자적 쓰기: tmp 파일에 쓰고 rename으로 교체 // 동시 쓰기 경쟁 상황에서 부분 쓰기(half-written file)로 인한 JSON 손상 방지. // Linux에서 rename()은 atomic syscall이므로 다른 리더가 항상 완전한 파일만 읽는다. - $tmpPath = STATE_PATH . '.tmp.' . getmypid() . '.' . uniqid(); + $tmpPath = STATE_PATH.'.tmp.'.getmypid().'.'.uniqid(); $result = @file_put_contents($tmpPath, $content, LOCK_EX); if ($result === false || $result !== strlen($content)) { - $msg = "[installer-state] Failed to write state tmp file: " . $tmpPath; + $msg = '[installer-state] Failed to write state tmp file: '.$tmpPath; error_log($msg); addLog($msg); @unlink($tmpPath); + return false; } @chmod($tmpPath, 0664); - if (!@rename($tmpPath, STATE_PATH)) { - $msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH; + if (! @rename($tmpPath, STATE_PATH)) { + $msg = '[installer-state] Failed to rename state tmp file: '.$tmpPath.' → '.STATE_PATH; error_log($msg); addLog($msg); @unlink($tmpPath); + return false; } @@ -161,13 +168,13 @@ function isInstallationCompleted(): bool } // g7_installed 파일 존재 여부 확인 (추가 안전장치) - $installedFlagPath = BASE_PATH . '/storage/app/g7_installed'; + $installedFlagPath = BASE_PATH.'/storage/app/g7_installed'; if (file_exists($installedFlagPath)) { return true; } // .env 파일의 INSTALLER_COMPLETED 플래그 확인 - $envPath = BASE_PATH . '/.env'; + $envPath = BASE_PATH.'/.env'; if (file_exists($envPath)) { $envContent = file_get_contents($envPath); if (strpos($envContent, 'INSTALLER_COMPLETED=true') !== false) { @@ -181,7 +188,7 @@ function isInstallationCompleted(): bool /** * 특정 작업을 완료로 표시 * - * @param string $task 작업 식별자 + * @param string $task 작업 식별자 * @return bool 업데이트 성공 여부 */ function markTaskCompleted(string $task): bool @@ -189,7 +196,7 @@ function markTaskCompleted(string $task): bool $state = getInstallationState(); // completed_tasks 배열에 추가 (중복 방지) - if (!in_array($task, $state['completed_tasks'])) { + if (! in_array($task, $state['completed_tasks'])) { $state['completed_tasks'][] = $task; } @@ -205,7 +212,7 @@ function markTaskCompleted(string $task): bool /** * 특정 작업을 완료 목록에서 제거 * - * @param string $task 작업 식별자 + * @param string $task 작업 식별자 * @return bool 업데이트 성공 여부 */ function removeTaskCompleted(string $task): bool @@ -213,7 +220,7 @@ function removeTaskCompleted(string $task): bool $state = getInstallationState(); $state['completed_tasks'] = array_values( - array_filter($state['completed_tasks'], fn($t) => $t !== $task) + array_filter($state['completed_tasks'], fn ($t) => $t !== $task) ); return saveInstallationState($state); @@ -251,7 +258,7 @@ function removeTaskCompleted(string $task): bool * * @param int $staleSeconds heartbeat 가 이 시간 이상 갱신 안 되면 stale 판정 (default 15초) * @return array{acquired: bool, worker_id: string|null, reason: string} - * reason: 'available' | 'takeover_stale' | 'busy' + * reason: 'available' | 'takeover_stale' | 'busy' */ function acquireWorkerLock(int $staleSeconds = 15): array { @@ -337,7 +344,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array $resetTasks = ['db_cleanup', 'db_migrate', 'db_seed']; $state['completed_tasks'] = array_values(array_filter( $state['completed_tasks'] ?? [], - fn($t) => !in_array($t, $resetTasks, true) + fn ($t) => ! in_array($t, $resetTasks, true) )); } else { // 키 정규화 — completed_tasks 가 부재인 경우 빈 배열 보장 @@ -350,7 +357,7 @@ function applyExistingDbActionStateGuard(array $state, string $newAction): array /** * 현재 진행 중인 작업 업데이트 * - * @param string $task 작업 식별자 + * @param string $task 작업 식별자 * @return bool 업데이트 성공 여부 */ function updateCurrentTask(string $task): bool @@ -369,7 +376,7 @@ function updateCurrentTask(string $task): bool * 페이지 새로고침/재시작 시 로그가 즉시 표시되도록 * fflush() + clearstatcache()를 적용합니다. * - * @param string $message 로그 메시지 + * @param string $message 로그 메시지 * @return bool 저장 성공 여부 */ /** @@ -389,23 +396,25 @@ function addLogBatch(array $messages): bool return true; } - $logDir = BASE_PATH . '/storage/logs'; - $logFile = $logDir . '/installation.log'; + $logDir = BASE_PATH.'/storage/logs'; + $logFile = $logDir.'/installation.log'; - if (!is_dir($logDir)) { + if (! is_dir($logDir)) { $created = @mkdir($logDir, 0775, true); - if (!$created) { + if (! $created) { $msg = "[addLogBatch] Failed to create log directory: {$logDir}"; error_log($msg); addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행 + return false; } } - if (!is_writable($logDir)) { + if (! is_writable($logDir)) { $msg = "[addLogBatch] Log directory is not writable: {$logDir}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 + return false; } @@ -425,9 +434,9 @@ function addLogBatch(array $messages): bool } clearstatcache(true, $logFile); - $isNewFile = !file_exists($logFile) || filesize($logFile) === 0; + $isNewFile = ! file_exists($logFile) || filesize($logFile) === 0; if ($isNewFile) { - $entries = "\xEF\xBB\xBF" . $entries; + $entries = "\xEF\xBB\xBF".$entries; } $handle = @fopen($logFile, 'a'); @@ -435,6 +444,7 @@ function addLogBatch(array $messages): bool $msg = "[addLogBatch] Failed to open log file: {$logFile}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 + return false; } flock($handle, LOCK_EX); @@ -455,7 +465,8 @@ function addLog(string $message): bool static $inAddLog = false; if ($inAddLog) { // 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료 - error_log("[addLog reentrant] " . $message); + error_log('[addLog reentrant] '.$message); + return false; } $inAddLog = true; @@ -478,25 +489,27 @@ function addLog(string $message): bool */ function _addLogInternal(string $message): bool { - $logDir = BASE_PATH . '/storage/logs'; - $logFile = $logDir . '/installation.log'; + $logDir = BASE_PATH.'/storage/logs'; + $logFile = $logDir.'/installation.log'; // 로그 디렉토리 확인 및 생성 시도 - if (!is_dir($logDir)) { + if (! is_dir($logDir)) { $created = @mkdir($logDir, 0775, true); - if (!$created) { + if (! $created) { $msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)"; error_log($msg); addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안) + return false; } } // 로그 디렉토리 쓰기 권한 확인 - if (!is_writable($logDir)) { + if (! is_writable($logDir)) { $msg = "[addLog] Log directory is not writable: {$logDir}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 + return false; } @@ -508,6 +521,17 @@ function _addLogInternal(string $message): bool } } + // OS 무관 최종 방어 — 항상 유효 UTF-8 만 로그에 저장한다 (gnuboard/g7#62). + // composer install 등 외부 프로세스 stdout 이 시스템 코드페이지로 출력되거나 + // 진행바(\r 갱신)가 임의 바이트 경계에서 잘려 mb_detect_encoding 이 확정 감지에 + // 실패한 경우(위 분기 미적용), invalid 바이트가 그대로 로그에 남으면 + // 폴링 응답 state-management.php 의 json_encode 가 false 를 반환해 빈 본문(HTTP 200) + // → 프론트 res.json() 이 "Unexpected end of JSON input" 으로 폭주한다. + // mb_scrub 은 invalid 바이트를 U+FFFD 로 치환해 응답 무력화를 원천 차단한다. + if (! mb_check_encoding($message, 'UTF-8')) { + $message = mb_scrub($message, 'UTF-8'); + } + // 타임스탬프와 함께 로그 작성 — millisecond 정밀도 (hang 진단 시 정확한 timing 필요) $microtime = microtime(true); $ms = sprintf('%03d', (int) (($microtime - floor($microtime)) * 1000)); @@ -518,10 +542,10 @@ function _addLogInternal(string $message): bool clearstatcache(true, $logFile); // 파일이 없거나 빈 파일이면 UTF-8 BOM 추가 (Windows 텍스트 편집기 호환성) - $isNewFile = !file_exists($logFile) || filesize($logFile) === 0; + $isNewFile = ! file_exists($logFile) || filesize($logFile) === 0; if ($isNewFile) { $utf8Bom = "\xEF\xBB\xBF"; - $logEntry = $utf8Bom . $logEntry; + $logEntry = $utf8Bom.$logEntry; } // 파일 핸들 열기 (append 모드) @@ -530,6 +554,7 @@ function _addLogInternal(string $message): bool $msg = "[addLog] Failed to open log file: {$logFile}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 + return false; } @@ -553,6 +578,7 @@ function _addLogInternal(string $message): bool $msg = "[addLog] Failed to write log file: {$logFile}"; error_log($msg); addLog($msg); // 재귀 가드가 차단 + return false; } @@ -565,16 +591,16 @@ function _addLogInternal(string $message): bool * 페이지 새로고침 시 최신 로그를 즉시 표시하기 위해 * clearstatcache()를 적용합니다. * - * @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0) + * @param int $offset 건너뛸 로그 줄 수 (폴링 모드 증분 조회용, 기본 0) * @return array 로그 배열 [{timestamp, message}, ...] */ function getInstallationLogs(int $offset = 0): array { - $logFile = BASE_PATH . '/storage/logs/installation.log'; + $logFile = BASE_PATH.'/storage/logs/installation.log'; clearstatcache(true, $logFile); - if (!file_exists($logFile)) { + if (! file_exists($logFile)) { return []; } @@ -621,11 +647,11 @@ function getInstallationLogs(int $offset = 0): array */ function getInstallationLogCount(): int { - $logFile = BASE_PATH . '/storage/logs/installation.log'; + $logFile = BASE_PATH.'/storage/logs/installation.log'; clearstatcache(true, $logFile); - if (!file_exists($logFile)) { + if (! file_exists($logFile)) { return 0; } @@ -637,10 +663,11 @@ function getInstallationLogCount(): int $lines = explode("\n", trim($content)); $count = 0; foreach ($lines as $line) { - if (!empty($line)) { + if (! empty($line)) { $count++; } } + return $count; } @@ -655,7 +682,7 @@ function getLastCompletedStep(): int // step_status를 역순으로 확인 for ($step = 5; $step >= 0; $step--) { - $stepKey = (string)$step; + $stepKey = (string) $step; if (isset($state['step_status'][$stepKey]) && $state['step_status'][$stepKey] === 'completed') { return $step; } diff --git a/public/install/includes/progress-emitter.php b/public/install/includes/progress-emitter.php index c1dc5a4d..aff29289 100644 --- a/public/install/includes/progress-emitter.php +++ b/public/install/includes/progress-emitter.php @@ -6,11 +6,8 @@ * SSE/폴링 듀얼 모드 지원을 위한 진행 상황 보고 인터페이스. * - SseEmitter: 실시간 SSE 스트리밍 + addLog * - NullEmitter: addLog만 수행 (폴링 모드, state.json 기반 조회) - * - * @package G7\Installer */ - -if (!defined('BASE_PATH')) { +if (! defined('BASE_PATH')) { define('BASE_PATH', realpath(dirname(__DIR__, 3)) ?: dirname(__DIR__, 3)); } @@ -19,8 +16,8 @@ interface ProgressEmitter /** * 임의 이벤트 송출 (SSE 이벤트 타입과 데이터 배열). * - * @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed) - * @param array $data 이벤트 데이터 + * @param string $event 이벤트 타입 (connected, task_start, task_complete, log, completed, error, aborted, rollback_failed) + * @param array $data 이벤트 데이터 */ public function emit(string $event, array $data): void; @@ -57,8 +54,22 @@ class SseEmitter implements ProgressEmitter public function emit(string $event, array $data): void { + // SSE data 라인은 항상 유효 JSON 이어야 한다 (gnuboard/g7#62). + // composer install 등 외부 프로세스 로그에 invalid UTF-8 바이트가 섞이면 + // json_encode 가 false 를 반환해 "data: \n\n" (빈 값) 이 송출되고, + // 프론트 EventSource 리스너의 JSON.parse(e.data) 가 throw 되어 로그 이벤트가 유실된다. + // 폴링 응답(state-management)과 동일하게 JSON_INVALID_UTF8_SUBSTITUTE 로 치환하고 + // false 를 가드한다. (task-runner 의 스트림 scrub 과 이중 안전망) + $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); + if ($payload === false) { + $payload = json_encode( + ['message' => '(log line dropped: encoding error)'], + JSON_UNESCAPED_UNICODE + ); + } + echo "event: {$event}\n"; - echo "data: " . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n"; + echo 'data: '.$payload."\n\n"; if (ob_get_level()) { @ob_flush(); } @@ -195,9 +206,10 @@ class NullEmitter implements ProgressEmitter */ function getProgressEmitter(): ProgressEmitter { - if (!isset($GLOBALS['g7_progress_emitter']) || !($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) { - $GLOBALS['g7_progress_emitter'] = new SseEmitter(); + if (! isset($GLOBALS['g7_progress_emitter']) || ! ($GLOBALS['g7_progress_emitter'] instanceof ProgressEmitter)) { + $GLOBALS['g7_progress_emitter'] = new SseEmitter; } + return $GLOBALS['g7_progress_emitter']; } diff --git a/public/install/includes/task-runner.php b/public/install/includes/task-runner.php index cbe25bb5..2caa6948 100644 --- a/public/install/includes/task-runner.php +++ b/public/install/includes/task-runner.php @@ -6,28 +6,26 @@ * 설치 task 정의와 실행 로직을 모드와 무관하게 제공합니다. * 진입점(install-worker.php SSE 모드, install-process.php 폴링 모드)이 * ProgressEmitter를 등록한 뒤 runInstallationTasks()를 호출합니다. - * - * @package G7\Installer */ -require_once __DIR__ . '/config.php'; -require_once __DIR__ . '/functions.php'; -require_once __DIR__ . '/installer-state.php'; -require_once __DIR__ . '/installer-runtime.php'; -require_once __DIR__ . '/progress-emitter.php'; -require_once __DIR__ . '/../api/rollback-functions.php'; +require_once __DIR__.'/config.php'; +require_once __DIR__.'/functions.php'; +require_once __DIR__.'/installer-state.php'; +require_once __DIR__.'/installer-runtime.php'; +require_once __DIR__.'/progress-emitter.php'; +require_once __DIR__.'/../api/rollback-functions.php'; // ============================================================================ // Emitter 호환 래퍼 — 기존 task 함수의 sendSSEEvent() 호출을 그대로 유지하면서 // 내부적으로 현재 등록된 ProgressEmitter로 delegate합니다. // ============================================================================ -if (!function_exists('sendSSEEvent')) { +if (! function_exists('sendSSEEvent')) { /** * 이벤트 송출 (SSE 모드/폴링 모드 공통). * - * @param string $event 이벤트 타입 - * @param array $data 이벤트 데이터 + * @param string $event 이벤트 타입 + * @param array $data 이벤트 데이터 */ function sendSSEEvent(string $event, array $data): void { @@ -35,17 +33,17 @@ if (!function_exists('sendSSEEvent')) { } } -if (!function_exists('sendRollbackOutputSSE')) { +if (! function_exists('sendRollbackOutputSSE')) { /** * 롤백 실행 결과를 로그 이벤트로 출력합니다. * - * @param array $rollbackResult rollbackDbMigrate() 등의 반환값 + * @param array $rollbackResult rollbackDbMigrate() 등의 반환값 */ function sendRollbackOutputSSE(array $rollbackResult): void { - if (!empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { + if (! empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { foreach ($rollbackResult['output'] as $line) { - if (!empty(trim($line))) { + if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -53,7 +51,7 @@ if (!function_exists('sendRollbackOutputSSE')) { } } -if (!function_exists('checkAbortStatusSSE')) { +if (! function_exists('checkAbortStatusSSE')) { /** * 설치 중단 여부 확인 (SSE/폴링 공용). * @@ -68,12 +66,12 @@ if (!function_exists('checkAbortStatusSSE')) { $state = getInstallationState(); $currentTask = $state['current_task'] ?? null; - if ($currentTask && !in_array($currentTask, $state['completed_tasks'] ?? [])) { + if ($currentTask && ! in_array($currentTask, $state['completed_tasks'] ?? [])) { addLog(lang('abort_rollback_start', ['task' => $currentTask])); $rollbackResult = rollbackTask($currentTask, $state); - if (!empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { + if (! empty($rollbackResult['output']) && is_array($rollbackResult['output'])) { $outputStr = implode("\n", $rollbackResult['output']); addLog($outputStr); } @@ -101,7 +99,7 @@ if (!function_exists('checkAbortStatusSSE')) { $state['abort_reason'] = 'Connection aborted unexpectedly'; $state['aborted_at'] = date('Y-m-d H:i:s'); - if (isset($rollbackResult) && !$rollbackResult['success']) { + if (isset($rollbackResult) && ! $rollbackResult['success']) { $state['rollback_failure'] = [ 'task' => $currentTask, 'message' => $rollbackResult['message'] ?? null, @@ -111,6 +109,7 @@ if (!function_exists('checkAbortStatusSSE')) { } saveInstallationState($state); + return true; } @@ -119,6 +118,7 @@ if (!function_exists('checkAbortStatusSSE')) { if (isset($state['installation_status']) && $state['installation_status'] === 'aborted') { $currentTask = $state['current_task'] ?? 'unknown'; addLog(lang('abort_by_user', ['task' => $currentTask])); + return true; } @@ -130,15 +130,16 @@ if (!function_exists('checkAbortStatusSSE')) { // Task 함수 정의 (install-worker.php에서 이관됨 — 동작 동일) // ============================================================================ -if (!function_exists('getPhpBinary')) { +if (! function_exists('getPhpBinary')) { function getPhpBinary(): string { $state = getInstallationState(); + return $state['config']['php_binary'] ?? 'php' ?: 'php'; } } -if (!function_exists('isInstallerExecutablePath')) { +if (! function_exists('isInstallerExecutablePath')) { /** * 인스톨러가 exec 에 전달하기 안전한 단일 토큰 경로인지 검증한다. * @@ -157,11 +158,12 @@ if (!function_exists('isInstallerExecutablePath')) { if (preg_match('/[\s;`$|<>"\'&\x00-\x1F]/', $path)) { return false; } + return true; } } -if (!function_exists('splitInstallerPhpComposerTokens')) { +if (! function_exists('splitInstallerPhpComposerTokens')) { /** * 공백 분리 입력을 "PHP 인터프리터 절대경로 + Composer 바이너리 절대경로" 두 토큰으로 분해. * @@ -169,16 +171,16 @@ if (!function_exists('splitInstallerPhpComposerTokens')) { * 운영 의도를 지원한다. 두 토큰 모두 isInstallerExecutablePath 통과해야 * 정상 입력으로 간주. * - * @return array{php: string, composer: string}|null 분해 실패 시 null + * @return array{php: string, composer: string}|null 분해 실패 시 null */ function splitInstallerPhpComposerTokens(string $path): ?array { - if (!str_contains($path, ' ')) { + if (! str_contains($path, ' ')) { return null; } $tokens = preg_split('/\s+/', trim($path), 2); - if (!is_array($tokens) || count($tokens) !== 2) { + if (! is_array($tokens) || count($tokens) !== 2) { return null; } @@ -191,7 +193,7 @@ if (!function_exists('splitInstallerPhpComposerTokens')) { } } -if (!function_exists('getComposerCommand')) { +if (! function_exists('getComposerCommand')) { function getComposerCommand(): string { $state = getInstallationState(); @@ -206,17 +208,18 @@ if (!function_exists('getComposerCommand')) { if (str_contains($composerBinary, ' ')) { $tokens = splitInstallerPhpComposerTokens($composerBinary); if ($tokens === null - || !isInstallerExecutablePath($tokens['php']) - || !isInstallerExecutablePath($tokens['composer']) + || ! isInstallerExecutablePath($tokens['php']) + || ! isInstallerExecutablePath($tokens['composer']) ) { return 'composer'; } - return escapeshellarg($tokens['php']) . ' ' . escapeshellarg($tokens['composer']); + + return escapeshellarg($tokens['php']).' '.escapeshellarg($tokens['composer']); } // 검증 실패 시 시스템 기본 'composer' 로 폴백 — 설치 흐름은 유지하되 // 사용자 입력이 셸 명령으로 흘러가지 않도록 차단. - if (!isInstallerExecutablePath($composerBinary)) { + if (! isInstallerExecutablePath($composerBinary)) { return 'composer'; } @@ -225,14 +228,15 @@ if (!function_exists('getComposerCommand')) { $phpArg = ($phpBinary !== 'php' && isInstallerExecutablePath($phpBinary)) ? escapeshellarg($phpBinary) : escapeshellarg('php'); - return $phpArg . ' ' . escapeshellarg($composerBinary); + + return $phpArg.' '.escapeshellarg($composerBinary); } return escapeshellarg($composerBinary); } } -if (!function_exists('getComposerCommandForDisplay')) { +if (! function_exists('getComposerCommandForDisplay')) { function getComposerCommandForDisplay(): string { $state = getInstallationState(); @@ -246,34 +250,35 @@ if (!function_exists('getComposerCommandForDisplay')) { if (str_contains($composerBinary, ' ')) { $tokens = splitInstallerPhpComposerTokens($composerBinary); if ($tokens === null - || !isInstallerExecutablePath($tokens['php']) - || !isInstallerExecutablePath($tokens['composer']) + || ! isInstallerExecutablePath($tokens['php']) + || ! isInstallerExecutablePath($tokens['composer']) ) { return 'composer'; } - return $tokens['php'] . ' ' . $tokens['composer']; + + return $tokens['php'].' '.$tokens['composer']; } - if (!isInstallerExecutablePath($composerBinary)) { + if (! isInstallerExecutablePath($composerBinary)) { return 'composer'; } if (str_ends_with($composerBinary, '.phar')) { - return getPhpBinary() . ' ' . $composerBinary; + return getPhpBinary().' '.$composerBinary; } return $composerBinary; } } -if (!function_exists('vendorModeOptionFromState')) { +if (! function_exists('vendorModeOptionFromState')) { /** * 인스톨러 state 의 vendor_mode 를 artisan 옵션 문자열로 변환합니다. * * 사용자가 Step 3 에서 선택한 vendor_mode 를 module:install/plugin:install * 등 확장 설치 커맨드로 일관되게 전파하기 위함. * - * @return string ' --vendor-mode=bundled' 같은 문자열 (선행 공백 포함). 미설정 시 빈 문자열. + * @return string ' --vendor-mode=bundled' 같은 문자열 (선행 공백 포함). 미설정 시 빈 문자열. */ function vendorModeOptionFromState(): string { @@ -288,7 +293,7 @@ if (!function_exists('vendorModeOptionFromState')) { } } -if (!function_exists('checkComposerSSE')) { +if (! function_exists('checkComposerSSE')) { /** * Composer 가용성 확인. * @@ -323,24 +328,24 @@ if (!function_exists('checkComposerSSE')) { sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); - $composerHome = BASE_PATH . '/storage/composer'; - if (!is_dir($composerHome)) { + $composerHome = BASE_PATH.'/storage/composer'; + if (! is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } - putenv('COMPOSER_HOME=' . $composerHome); - putenv('HOME=' . $composerHome); + putenv('COMPOSER_HOME='.$composerHome); + putenv('HOME='.$composerHome); applyInstallerComposerEnvVars(); $output = []; $returnCode = 0; $composerCmd = getComposerCommand(); - exec($composerCmd . ' --version 2>&1', $output, $returnCode); + exec($composerCmd.' --version 2>&1', $output, $returnCode); if ($returnCode !== 0) { $errorMessage = implode("\n", $output); // auto 모드: composer 미설치 시 번들 폴백 가능하면 비치명적 처리 - if ($vendorMode === 'auto' && file_exists(BASE_PATH . '/vendor-bundle.zip')) { + if ($vendorMode === 'auto' && file_exists(BASE_PATH.'/vendor-bundle.zip')) { sendSSEEvent('log', ['message' => lang('log_composer_check_auto_fallback')]); sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('composer_check'); @@ -351,6 +356,7 @@ if (!function_exists('checkComposerSSE')) { sendSSEEvent('log', ['message' => lang('log_error_occurred', ['error' => $errorMessage])]); logInstallationError(lang('error_composer_not_installed')); + return [ 'success' => false, 'message' => lang('error_composer_not_installed'), @@ -373,13 +379,13 @@ if (!function_exists('checkComposerSSE')) { } } -if (!function_exists('installVendorBundleSSE')) { +if (! function_exists('installVendorBundleSSE')) { /** * vendor-bundle.zip 을 추출하여 vendor/ 를 구성합니다 (bundled 모드용). */ function installVendorBundleSSE(): array { - require_once __DIR__ . '/vendor-bundle-installer.php'; + require_once __DIR__.'/vendor-bundle-installer.php'; if (checkAbortStatusSSE()) { return ['success' => false, 'aborted' => true]; @@ -391,10 +397,11 @@ if (!function_exists('installVendorBundleSSE')) { sendSSEEvent('log', ['message' => 'Vendor 번들 추출을 시작합니다...']); $integrity = verifyVendorBundle(BASE_PATH); - if (!$integrity['valid']) { + if (! $integrity['valid']) { $errorList = implode(', ', $integrity['errors']); sendSSEEvent('log', ['message' => "번들 무결성 검증 실패: {$errorList}"]); logInstallationError("vendor-bundle.zip 무결성 검증 실패: {$errorList}"); + return [ 'success' => false, 'message' => "vendor-bundle.zip 무결성 검증 실패: {$errorList}", @@ -407,10 +414,11 @@ if (!function_exists('installVendorBundleSSE')) { $result = extractVendorBundle(BASE_PATH, BASE_PATH); - if (!$result['success']) { + if (! $result['success']) { $error = $result['error'] ?? 'unknown'; sendSSEEvent('log', ['message' => "vendor 번들 추출 실패: {$error}"]); logInstallationError("vendor 번들 추출 실패: {$error}"); + return [ 'success' => false, 'message' => "vendor 번들 추출 실패: {$error}", @@ -435,11 +443,11 @@ if (!function_exists('installVendorBundleSSE')) { } } -if (!function_exists('installComposerDependenciesSSE')) { +if (! function_exists('installComposerDependenciesSSE')) { function installComposerDependenciesSSE(): array { // Laravel compiled cache 정리 헬퍼(clearLaravelCompiledCache)가 정의된 shim - require_once __DIR__ . '/vendor-bundle-installer.php'; + require_once __DIR__.'/vendor-bundle-installer.php'; // Vendor 모드에 따라 분기: bundled → vendor-bundle.zip 추출 $state = getInstallationState(); @@ -454,10 +462,11 @@ if (!function_exists('installComposerDependenciesSSE')) { $composerBinary = $state['config']['composer_binary'] ?? ''; $phpBinary = $state['config']['php_binary'] ?? 'php'; - if (!canExecuteComposerForInstall($composerBinary, $phpBinary)) { + if (! canExecuteComposerForInstall($composerBinary, $phpBinary)) { // composer 사용 불가 → 번들 zip 존재 여부 확인 후 폴백 - if (file_exists(BASE_PATH . '/vendor-bundle.zip')) { + if (file_exists(BASE_PATH.'/vendor-bundle.zip')) { sendSSEEvent('log', ['message' => 'Composer 실행 불가 환경 감지 → 번들 vendor 모드로 자동 전환']); + return installVendorBundleSSE(); } } @@ -473,8 +482,8 @@ if (!function_exists('installComposerDependenciesSSE')) { sendSSEEvent('task_start', ['task' => 'composer_install', 'name' => $taskName]); sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); - $vendorExists = is_dir(BASE_PATH . '/vendor') && file_exists(BASE_PATH . '/vendor/autoload.php'); - $lockExists = file_exists(BASE_PATH . '/composer.lock'); + $vendorExists = is_dir(BASE_PATH.'/vendor') && file_exists(BASE_PATH.'/vendor/autoload.php'); + $lockExists = file_exists(BASE_PATH.'/composer.lock'); if ($vendorExists && $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_already_installed')]); @@ -483,26 +492,27 @@ if (!function_exists('installComposerDependenciesSSE')) { markTaskCompleted('composer_install'); sendSSEEvent('task_complete', ['task' => 'composer_install', 'message' => lang('log_composer_already_installed')]); + return ['success' => true]; } - if ($vendorExists && !$lockExists) { + if ($vendorExists && ! $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_vendor_without_lock')]); sendSSEEvent('log', ['message' => lang('log_composer_removing_vendor')]); - $deleted = deleteDirectory(BASE_PATH . '/vendor'); - if (!$deleted) { + $deleted = deleteDirectory(BASE_PATH.'/vendor'); + if (! $deleted) { sendSSEEvent('log', ['message' => lang('log_composer_vendor_delete_failed')]); } else { sendSSEEvent('log', ['message' => lang('log_composer_vendor_deleted')]); } } - if (!$vendorExists && $lockExists) { + if (! $vendorExists && $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_installing_from_lock')]); } - if (!$vendorExists && !$lockExists) { + if (! $vendorExists && ! $lockExists) { sendSSEEvent('log', ['message' => lang('log_composer_fresh_install')]); } @@ -516,8 +526,8 @@ if (!function_exists('installComposerDependenciesSSE')) { chdir(BASE_PATH); - $composerHome = BASE_PATH . '/storage/composer'; - if (!is_dir($composerHome)) { + $composerHome = BASE_PATH.'/storage/composer'; + if (! is_dir($composerHome)) { @mkdir($composerHome, 0755, true); } @@ -535,9 +545,9 @@ if (!function_exists('installComposerDependenciesSSE')) { $env = array_merge($env, buildInstallerComposerEnv()); if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { - if (!isset($env['TEMP']) || !is_dir($env['TEMP']) || !is_writable($env['TEMP'])) { - $tempDir = BASE_PATH . '/storage/temp'; - if (!is_dir($tempDir)) { + if (! isset($env['TEMP']) || ! is_dir($env['TEMP']) || ! is_writable($env['TEMP'])) { + $tempDir = BASE_PATH.'/storage/temp'; + if (! is_dir($tempDir)) { @mkdir($tempDir, 0755, true); } $env['TEMP'] = $tempDir; @@ -553,15 +563,16 @@ if (!function_exists('installComposerDependenciesSSE')) { $composerCmd = getComposerCommand(); $process = proc_open( - $composerCmd . ' install --no-interaction --no-dev --optimize-autoloader --no-ansi 2>&1', + $composerCmd.' install --no-interaction --no-dev --optimize-autoloader --no-ansi 2>&1', $descriptorspec, $pipes, BASE_PATH, $env ); - if (!is_resource($process)) { + if (! is_resource($process)) { logInstallationError(lang('error_composer_install_failed')); + return [ 'success' => false, 'message' => lang('error_composer_install_failed'), @@ -573,17 +584,26 @@ if (!function_exists('installComposerDependenciesSSE')) { fclose($pipes[0]); stream_set_blocking($pipes[1], false); - while (!feof($pipes[1])) { + while (! feof($pipes[1])) { if (checkAbortStatusSSE()) { proc_terminate($process); proc_close($process); + return ['success' => false, 'aborted' => true]; } $line = fgets($pipes[1]); if ($line !== false) { $line = trim($line); - if (!empty($line)) { + // composer stdout 이 시스템 코드페이지(Windows CP949 등)로 출력하거나 + // 진행바(\r 갱신)가 non-blocking 읽기와 겹쳐 임의 바이트 경계에서 잘리면 + // invalid UTF-8 바이트가 섞인다. SSE 응답(progress-emitter 의 json_encode)과 + // 폴링 응답(state-management) 양쪽이 무력화되지 않도록 로그 전송 전 정규화한다. + // (gnuboard/g7#62 — addLog 최종 방어와 이중 안전망) + if ($line !== '' && ! mb_check_encoding($line, 'UTF-8')) { + $line = mb_scrub($line, 'UTF-8'); + } + if (! empty($line)) { sendSSEEvent('log', ['message' => $line]); } } @@ -596,6 +616,7 @@ if (!function_exists('installComposerDependenciesSSE')) { if ($returnCode !== 0) { logInstallationError(lang('error_composer_install_failed')); + return [ 'success' => false, 'message' => lang('error_composer_install_failed'), @@ -614,7 +635,7 @@ if (!function_exists('installComposerDependenciesSSE')) { } } -if (!function_exists('updateEnvFileSSE')) { +if (! function_exists('updateEnvFileSSE')) { /** * 동적 설정(DB 자격증명) 을 storage/installer/runtime.php 에 작성한다. * @@ -640,9 +661,10 @@ if (!function_exists('updateEnvFileSSE')) { sendSSEEvent('log', ['message' => lang('log_task_in_progress', ['task' => $taskName])]); // .env.example 존재 여부만 확인 — finalize 단계에서 generateEnvContent() 가 사용 - $envExamplePath = BASE_PATH . '/.env.example'; - if (!file_exists($envExamplePath)) { + $envExamplePath = BASE_PATH.'/.env.example'; + if (! file_exists($envExamplePath)) { logInstallationError(lang('error_env_example_not_found')); + return [ 'success' => false, 'message' => lang('error_env_example_not_found'), @@ -655,8 +677,9 @@ if (!function_exists('updateEnvFileSSE')) { $runtime = buildInstallerRuntimeFromState($stateConfig); - if (!writeInstallerRuntime($runtime)) { + if (! writeInstallerRuntime($runtime)) { logInstallationError(lang('error_env_write_failed', ['path' => INSTALLER_RUNTIME_PATH])); + return [ 'success' => false, 'message' => lang('error_env_write_failed', ['path' => INSTALLER_RUNTIME_PATH]), @@ -675,7 +698,7 @@ if (!function_exists('updateEnvFileSSE')) { } } -if (!function_exists('generateApplicationKeySSE')) { +if (! function_exists('generateApplicationKeySSE')) { /** * APP_KEY 를 pure PHP 로 생성하여 runtime.php 에 기록한다. * @@ -694,7 +717,7 @@ if (!function_exists('generateApplicationKeySSE')) { } // 재시도 경로 방어 — composer_install 재진입 시 stale bootstrap/cache 정리 - require_once __DIR__ . '/vendor-bundle-installer.php'; + require_once __DIR__.'/vendor-bundle-installer.php'; clearLaravelCompiledCache(BASE_PATH); $taskName = lang('task_key_generate'); @@ -725,8 +748,9 @@ if (!function_exists('generateApplicationKeySSE')) { $runtime['app']['key'] = $key; $runtime['created_at'] = $runtime['created_at'] ?? date('c'); - if (!writeInstallerRuntime($runtime)) { + if (! writeInstallerRuntime($runtime)) { logInstallationError(lang('error_key_generate_failed')); + return [ 'success' => false, 'message' => lang('error_key_generate_failed'), @@ -745,7 +769,7 @@ if (!function_exists('generateApplicationKeySSE')) { } } -if (!function_exists('dependencyPrecheckSSE')) { +if (! function_exists('dependencyPrecheckSSE')) { /** * 의존성 사전 검증 (백엔드 안전망). * @@ -777,13 +801,13 @@ if (!function_exists('dependencyPrecheckSSE')) { // 확장 매니페스트 읽기 (모듈/플러그인 — 템플릿은 dependencies 사용 빈도 낮음) $scanTargets = [ - 'modules' => BASE_PATH . '/modules/_bundled', - 'plugins' => BASE_PATH . '/plugins/_bundled', - 'templates' => BASE_PATH . '/templates/_bundled', + 'modules' => BASE_PATH.'/modules/_bundled', + 'plugins' => BASE_PATH.'/plugins/_bundled', + 'templates' => BASE_PATH.'/templates/_bundled', ]; foreach ($scanTargets as $type => $baseDir) { - if (!is_dir($baseDir)) { + if (! is_dir($baseDir)) { continue; } $dirs = scandir($baseDir) ?: []; @@ -799,23 +823,23 @@ if (!function_exists('dependencyPrecheckSSE')) { } elseif ($type === 'templates' && file_exists("{$baseDir}/{$dir}/template.json")) { $manifestFile = "{$baseDir}/{$dir}/template.json"; } - if (!$manifestFile) { + if (! $manifestFile) { continue; } $data = json_decode((string) @file_get_contents($manifestFile), true); - if (!is_array($data)) { + if (! is_array($data)) { continue; } $identifier = $data['identifier'] ?? $dir; // 선택된 확장만 검증 대상 - if (!isset($selectedIdSet[$identifier])) { + if (! isset($selectedIdSet[$identifier])) { continue; } $deps = $data['dependencies'] ?? []; - if (!is_array($deps)) { + if (! is_array($deps)) { continue; } @@ -831,7 +855,7 @@ if (!function_exists('dependencyPrecheckSSE')) { $check[] = is_int($depId) ? $_ : $depId; } } - if (!isset($deps['modules']) && !isset($deps['plugins'])) { + if (! isset($deps['modules']) && ! isset($deps['plugins'])) { foreach ($deps as $depId) { if (is_string($depId)) { $check[] = $depId; @@ -840,19 +864,20 @@ if (!function_exists('dependencyPrecheckSSE')) { } foreach ($check as $depId) { - if (!isset($selectedIdSet[$depId])) { + if (! isset($selectedIdSet[$depId])) { $missing[] = "{$identifier} → {$depId}"; } } } } - if (!empty($missing)) { + if (! empty($missing)) { $errorMessage = lang('dependency_precheck_failed'); foreach ($missing as $line) { - sendSSEEvent('log', ['message' => ' - ' . $line]); + sendSSEEvent('log', ['message' => ' - '.$line]); } logInstallationError($errorMessage); + return [ 'success' => false, 'message' => $errorMessage, @@ -871,7 +896,7 @@ if (!function_exists('dependencyPrecheckSSE')) { } } -if (!function_exists('cleanupExistingTablesSSE')) { +if (! function_exists('cleanupExistingTablesSSE')) { /** * 기존 DB 테이블 정리. * @@ -902,6 +927,7 @@ if (!function_exists('cleanupExistingTablesSSE')) { sendSSEEvent('log', ['message' => lang('log_separator')]); markTaskCompleted('db_cleanup'); sendSSEEvent('task_complete', ['task' => 'db_cleanup', 'message' => lang('log_db_cleanup_skipped')]); + return ['success' => true]; } @@ -926,7 +952,7 @@ if (!function_exists('cleanupExistingTablesSSE')) { $pdo->exec('SET FOREIGN_KEY_CHECKS = 0'); foreach ($tables as $table) { - $quoted = '`' . str_replace('`', '``', $table) . '`'; + $quoted = '`'.str_replace('`', '``', $table).'`'; $pdo->exec("DROP TABLE IF EXISTS {$quoted}"); sendSSEEvent('log', ['message' => " - DROP TABLE {$table}"]); } @@ -944,6 +970,7 @@ if (!function_exists('cleanupExistingTablesSSE')) { return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_db_cleanup_failed'), $e); + return [ 'success' => false, 'message' => lang('error_db_cleanup_failed'), @@ -954,7 +981,7 @@ if (!function_exists('cleanupExistingTablesSSE')) { } } -if (!function_exists('runDatabaseMigrationSSE')) { +if (! function_exists('runDatabaseMigrationSSE')) { function runDatabaseMigrationSSE(): array { return executeDbCommandSSE( @@ -967,7 +994,7 @@ if (!function_exists('runDatabaseMigrationSSE')) { } } -if (!function_exists('runDatabaseSeedingSSE')) { +if (! function_exists('runDatabaseSeedingSSE')) { function runDatabaseSeedingSSE(): array { $state = getInstallationState(); @@ -977,10 +1004,10 @@ if (!function_exists('runDatabaseSeedingSSE')) { return ['success' => false, 'error' => '관리자 이메일과 비밀번호가 설정되지 않았습니다.']; } - putenv('INSTALLER_ADMIN_NAME=' . ($config['admin_name'] ?? 'Administrator')); - putenv('INSTALLER_ADMIN_EMAIL=' . $config['admin_email']); - putenv('INSTALLER_ADMIN_PASSWORD=' . $config['admin_password']); - putenv('INSTALLER_ADMIN_LANGUAGE=' . ($config['admin_language'] ?? $state['g7_locale'] ?? 'ko')); + putenv('INSTALLER_ADMIN_NAME='.($config['admin_name'] ?? 'Administrator')); + putenv('INSTALLER_ADMIN_EMAIL='.$config['admin_email']); + putenv('INSTALLER_ADMIN_PASSWORD='.$config['admin_password']); + putenv('INSTALLER_ADMIN_LANGUAGE='.($config['admin_language'] ?? $state['g7_locale'] ?? 'ko')); return executeDbCommandSSE( artisanCommand: 'db:seed --force', @@ -992,10 +1019,11 @@ if (!function_exists('runDatabaseSeedingSSE')) { } } -if (!function_exists('getSelectedExtensions')) { +if (! function_exists('getSelectedExtensions')) { function getSelectedExtensions(): array { $state = getInstallationState(); + return $state['selected_extensions'] ?? [ 'admin_templates' => [], 'user_templates' => [], @@ -1006,7 +1034,7 @@ if (!function_exists('getSelectedExtensions')) { } } -if (!function_exists('reserveCommandOutputFile')) { +if (! function_exists('reserveCommandOutputFile')) { /** * artisan 명령 stdout/stderr redirect 용 임시 로그 파일 경로 예약. * @@ -1027,7 +1055,7 @@ if (!function_exists('reserveCommandOutputFile')) { ]; foreach ($candidates as $dir) { - if (!is_dir($dir)) { + if (! is_dir($dir)) { @mkdir($dir, 0755, true); } if (is_dir($dir) && is_writable($dir)) { @@ -1041,7 +1069,7 @@ if (!function_exists('reserveCommandOutputFile')) { } } -if (!function_exists('executeArtisanCommandSSE')) { +if (! function_exists('executeArtisanCommandSSE')) { function executeArtisanCommandSSE( string $artisanCommand, string $taskId, @@ -1066,7 +1094,7 @@ if (!function_exists('executeArtisanCommandSSE')) { $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " - . escapeshellarg($cmdLogFile) . " 2>&1"; + .escapeshellarg($cmdLogFile).' 2>&1'; exec($fullCommand, $_ignored, $returnCode); if (file_exists($cmdLogFile)) { @@ -1075,7 +1103,7 @@ if (!function_exists('executeArtisanCommandSSE')) { } foreach ($output as $line) { - if (!empty(trim($line))) { + if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -1083,6 +1111,7 @@ if (!function_exists('executeArtisanCommandSSE')) { if ($returnCode !== 0) { $errorMessage = implode("\n", $output); logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); + return [ 'success' => false, 'message' => lang($errorMsgKey), @@ -1101,7 +1130,7 @@ if (!function_exists('executeArtisanCommandSSE')) { } } -if (!function_exists('executeDbCommandSSE')) { +if (! function_exists('executeDbCommandSSE')) { function executeDbCommandSSE( string $artisanCommand, string $taskId, @@ -1114,6 +1143,7 @@ if (!function_exists('executeDbCommandSSE')) { if ($wasAborted) { addLog(lang('db_task_abort_detected_before_start')); + return ['success' => false, 'aborted' => true]; } @@ -1130,7 +1160,7 @@ if (!function_exists('executeDbCommandSSE')) { $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " - . escapeshellarg($cmdLogFile) . " 2>&1"; + .escapeshellarg($cmdLogFile).' 2>&1'; exec($fullCommand, $_ignored, $returnCode); @@ -1140,7 +1170,7 @@ if (!function_exists('executeDbCommandSSE')) { } foreach ($output as $line) { - if (!empty(trim($line))) { + if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -1220,7 +1250,7 @@ if (!function_exists('executeDbCommandSSE')) { } } -if (!function_exists('executeExtensionCommandSSE')) { +if (! function_exists('executeExtensionCommandSSE')) { function executeExtensionCommandSSE( string $artisanCommand, string $taskId, @@ -1263,7 +1293,7 @@ if (!function_exists('executeExtensionCommandSSE')) { $cmdLogFile = reserveCommandOutputFile(); $fullCommand = "{$phpBin} -d memory_limit=512M artisan {$artisanCommand} > " - . escapeshellarg($cmdLogFile) . " 2>&1"; + .escapeshellarg($cmdLogFile).' 2>&1'; exec($fullCommand, $_ignored, $returnCode); @@ -1289,7 +1319,7 @@ if (!function_exists('executeExtensionCommandSSE')) { $emitter->emit('heartbeat', []); } else { foreach ($output as $line) { - if (!empty(trim($line))) { + if (! empty(trim($line))) { sendSSEEvent('log', ['message' => $line]); } } @@ -1306,9 +1336,10 @@ if (!function_exists('executeExtensionCommandSSE')) { } } - if ($returnCode !== 0 && !$isAlreadyExists) { + if ($returnCode !== 0 && ! $isAlreadyExists) { $errorMessage = $outputText; logInstallationError(lang($errorMsgKey), new Exception($errorMessage)); + return [ 'success' => false, 'message' => lang($errorMsgKey), @@ -1338,7 +1369,7 @@ if (!function_exists('executeExtensionCommandSSE')) { } } -if (!function_exists('installAdminTemplateSSE')) { +if (! function_exists('installAdminTemplateSSE')) { function installAdminTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1352,7 +1383,7 @@ if (!function_exists('installAdminTemplateSSE')) { } } -if (!function_exists('activateAdminTemplateSSE')) { +if (! function_exists('activateAdminTemplateSSE')) { function activateAdminTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1366,7 +1397,7 @@ if (!function_exists('activateAdminTemplateSSE')) { } } -if (!function_exists('installModuleSSE')) { +if (! function_exists('installModuleSSE')) { function installModuleSSE(string $moduleId): array { $vendorModeOpt = vendorModeOptionFromState(); @@ -1382,7 +1413,7 @@ if (!function_exists('installModuleSSE')) { } } -if (!function_exists('activateModuleSSE')) { +if (! function_exists('activateModuleSSE')) { function activateModuleSSE(string $moduleId): array { return executeExtensionCommandSSE( @@ -1396,7 +1427,7 @@ if (!function_exists('activateModuleSSE')) { } } -if (!function_exists('installPluginSSE')) { +if (! function_exists('installPluginSSE')) { function installPluginSSE(string $pluginId): array { $vendorModeOpt = vendorModeOptionFromState(); @@ -1412,7 +1443,7 @@ if (!function_exists('installPluginSSE')) { } } -if (!function_exists('activatePluginSSE')) { +if (! function_exists('activatePluginSSE')) { function activatePluginSSE(string $pluginId): array { return executeExtensionCommandSSE( @@ -1426,7 +1457,7 @@ if (!function_exists('activatePluginSSE')) { } } -if (!function_exists('installUserTemplateSSE')) { +if (! function_exists('installUserTemplateSSE')) { function installUserTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1440,7 +1471,7 @@ if (!function_exists('installUserTemplateSSE')) { } } -if (!function_exists('activateUserTemplateSSE')) { +if (! function_exists('activateUserTemplateSSE')) { function activateUserTemplateSSE(string $templateId): array { return executeExtensionCommandSSE( @@ -1454,7 +1485,7 @@ if (!function_exists('activateUserTemplateSSE')) { } } -if (!function_exists('installLanguagePackSSE')) { +if (! function_exists('installLanguagePackSSE')) { /** * 번들 언어팩 설치 (best-effort). * @@ -1478,7 +1509,7 @@ if (!function_exists('installLanguagePackSSE')) { } } -if (!function_exists('clearCacheSSE')) { +if (! function_exists('clearCacheSSE')) { function clearCacheSSE(): array { return executeArtisanCommandSSE( @@ -1491,7 +1522,7 @@ if (!function_exists('clearCacheSSE')) { } } -if (!function_exists('optimizeConfigCacheSSE')) { +if (! function_exists('optimizeConfigCacheSSE')) { /** * 설치 완료 직후 config 캐시를 최초 생성한다. * @@ -1513,7 +1544,7 @@ if (!function_exists('optimizeConfigCacheSSE')) { } } -if (!function_exists('createSettingsJsonSSE')) { +if (! function_exists('createSettingsJsonSSE')) { function createSettingsJsonSSE(): array { if (checkAbortStatusSSE()) { @@ -1528,22 +1559,22 @@ if (!function_exists('createSettingsJsonSSE')) { sendSSEEvent('log', ['message' => lang('log_creating_settings')]); try { - $settingsDir = BASE_PATH . '/storage/app/settings'; + $settingsDir = BASE_PATH.'/storage/app/settings'; - if (!is_dir($settingsDir)) { + if (! is_dir($settingsDir)) { mkdir($settingsDir, 0755, true); } - $defaultsFile = BASE_PATH . '/config/settings/defaults.json'; - if (!file_exists($defaultsFile)) { - throw new Exception('defaults.json file not found: ' . $defaultsFile); + $defaultsFile = BASE_PATH.'/config/settings/defaults.json'; + if (! file_exists($defaultsFile)) { + throw new Exception('defaults.json file not found: '.$defaultsFile); } $defaultsContent = file_get_contents($defaultsFile); $defaultsData = json_decode($defaultsContent, true); if (json_last_error() !== JSON_ERROR_NONE) { - throw new Exception('defaults.json JSON parsing failed: ' . json_last_error_msg()); + throw new Exception('defaults.json JSON parsing failed: '.json_last_error_msg()); } $defaults = $defaultsData['defaults'] ?? []; @@ -1556,30 +1587,31 @@ if (!function_exists('createSettingsJsonSSE')) { $state = getInstallationState(); $config = $state['config'] ?? []; - if (!empty($config['app_name'])) { + if (! empty($config['app_name'])) { $defaults['general']['site_name'] = $config['app_name']; $defaults['mail']['from_name'] = $config['app_name']; } - if (!empty($config['app_url'])) { + if (! empty($config['app_url'])) { $defaults['general']['site_url'] = $config['app_url']; } - if (!empty($config['admin_email'])) { + if (! empty($config['admin_email'])) { $defaults['general']['admin_email'] = $config['admin_email']; $defaults['mail']['from_address'] = $config['admin_email']; } - if (!empty($config['core_update_github_url'])) { + if (! empty($config['core_update_github_url'])) { $defaults['core_update']['github_url'] = $config['core_update_github_url']; } - if (!empty($config['core_update_github_token'])) { + if (! empty($config['core_update_github_token'])) { $defaults['core_update']['github_token'] = $config['core_update_github_token']; } $defaults['general']['language'] = getCurrentLanguage(); foreach ($categories as $category) { - if (!isset($defaults[$category])) { + if (! isset($defaults[$category])) { sendSSEEvent('log', ['message' => " - {$category}.json skipped (no defaults)"]); + continue; } @@ -1593,7 +1625,7 @@ if (!function_exists('createSettingsJsonSSE')) { $data = array_merge($data, $settings); $json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); - $filePath = $settingsDir . '/' . $category . '.json'; + $filePath = $settingsDir.'/'.$category.'.json'; file_put_contents($filePath, $json, LOCK_EX); sendSSEEvent('log', ['message' => " - {$category}.json created"]); @@ -1609,6 +1641,7 @@ if (!function_exists('createSettingsJsonSSE')) { return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_settings_json_failed'), $e); + return [ 'success' => false, 'message' => lang('error_settings_json_failed'), @@ -1619,7 +1652,7 @@ if (!function_exists('createSettingsJsonSSE')) { } } -if (!function_exists('setInstallationCompleteSSE')) { +if (! function_exists('setInstallationCompleteSSE')) { function setInstallationCompleteSSE(): array { if (checkAbortStatusSSE()) { @@ -1638,10 +1671,10 @@ if (!function_exists('setInstallationCompleteSSE')) { // 진행 중 재시작을 일으키지 않도록 한다. g7_installed 파일과 state.json // 의 completed 마커만으로 "Step 5 완료" 시점을 보장. - $installedFlagPath = BASE_PATH . '/storage/app/g7_installed'; + $installedFlagPath = BASE_PATH.'/storage/app/g7_installed'; $installedFlagDir = dirname($installedFlagPath); - if (!is_dir($installedFlagDir)) { + if (! is_dir($installedFlagDir)) { @mkdir($installedFlagDir, 0775, true); } @@ -1651,7 +1684,7 @@ if (!function_exists('setInstallationCompleteSSE')) { // 인스톨러 작업 중 composer install 등이 storage/temp 에 남긴 Symfony Process // sf_proc_*.{out,err,lock} 잔여 파일 정리. 디렉토리 자체는 보존 (다음 사용 대비). - $tempDir = BASE_PATH . '/storage/temp'; + $tempDir = BASE_PATH.'/storage/temp'; if (is_dir($tempDir)) { $entries = @scandir($tempDir); if (is_array($entries)) { @@ -1659,7 +1692,7 @@ if (!function_exists('setInstallationCompleteSSE')) { if ($entry === '.' || $entry === '..') { continue; } - $entryPath = $tempDir . '/' . $entry; + $entryPath = $tempDir.'/'.$entry; if (is_file($entryPath)) { @unlink($entryPath); } @@ -1689,6 +1722,7 @@ if (!function_exists('setInstallationCompleteSSE')) { return ['success' => true]; } catch (Exception $e) { logInstallationError(lang('error_complete_flag_failed'), $e); + return [ 'success' => false, 'message' => lang('error_complete_flag_failed'), @@ -1703,7 +1737,7 @@ if (!function_exists('setInstallationCompleteSSE')) { // 메인 실행 로직 // ============================================================================ -if (!function_exists('runInstallationTasks')) { +if (! function_exists('runInstallationTasks')) { /** * 설치 작업 전체 실행. * @@ -1791,6 +1825,7 @@ if (!function_exists('runInstallationTasks')) { 'target' => $task['target'] ?? null, ]); addLog(lang('abort_installation_stopped')); + return; } @@ -1812,6 +1847,7 @@ if (!function_exists('runInstallationTasks')) { 'target' => $target, 'message' => lang('log_already_completed', ['task' => $displayName]), ]); + continue; } @@ -1824,15 +1860,17 @@ if (!function_exists('runInstallationTasks')) { 'target' => $target, ]); addLog(lang('abort_installation_stopped')); + return; } - if (!$result['success']) { + if (! $result['success']) { // best-effort task (예: 번들 언어팩 설치) — 실패 시 경고 로그만 남기고 계속 진행 if ($bestEffort) { $warnMsg = lang('warning_language_pack_install_partial', ['identifier' => (string) $target]); sendSSEEvent('log', ['message' => $warnMsg]); addLog($warnMsg); + // task_complete 이벤트는 보내지 않고 다음 task 로 진행 (재시도 시 다시 시도 가능) continue; } @@ -1891,7 +1929,7 @@ if (!function_exists('runInstallationTasks')) { $state['error_message_key'] = $result['message_key'] ?? null; $state['error_detail'] = $result['detail'] ?? null; - if (!empty($result['rollback_done'])) { + if (! empty($result['rollback_done'])) { $state['rollback_failure'] = $result['rollback_failure'] ?? null; } else { $state['rollback_failure'] = $rollbackFailure ?? null; @@ -1907,7 +1945,7 @@ if (!function_exists('runInstallationTasks')) { 'message' => $result['message'] ?? lang('log_installation_failed'), ])); - if (!empty($manualCommands)) { + if (! empty($manualCommands)) { sendSSEEvent('log', ['message' => lang('log_separator')]); sendSSEEvent('log', ['message' => lang('manual_commands_guide')]); foreach ($manualCommands as $cmd) { @@ -1924,6 +1962,7 @@ if (!function_exists('runInstallationTasks')) { 'target' => $target, 'manual_commands' => $manualCommands, ]); + return; } } diff --git a/tests/Unit/Installer/AddLogUtf8ScrubTest.php b/tests/Unit/Installer/AddLogUtf8ScrubTest.php new file mode 100644 index 00000000..71c0c6d9 --- /dev/null +++ b/tests/Unit/Installer/AddLogUtf8ScrubTest.php @@ -0,0 +1,251 @@ +markTestSkipped(self::$skipReason); + } + + // 각 테스트 시작 시 로그 초기화 + $logFile = self::$sharedBase.'/storage/logs/installation.log'; + if (file_exists($logFile)) { + @unlink($logFile); + } + } + + /** + * invalid UTF-8 바이트 표본. + * + * - 잘린 한글 선행 바이트 (EC 99 은 3바이트 UTF-8 의 앞 2바이트, 마지막 바이트 누락) + * - 단독 0xFF (UTF-8 에서 절대 유효하지 않은 바이트) + * - CP949 로 인코딩된 한글이 감지 실패하는 케이스 모사 + * + * @return array + */ + public static function invalidUtf8Provider(): array + { + return [ + 'truncated hangul lead bytes' => ["Downloading \xEC\x99 package"], + 'lone 0xFF byte' => ["progress: abc\xFFdef 100%"], + 'truncated 4-byte sequence' => ["emoji \xF0\x9F head"], // 4바이트 UTF-8 의 앞 2바이트만 + 'lone continuation byte' => ["tail\x80end"], + ]; + } + + #[Test] + #[DataProvider('invalidUtf8Provider')] + public function addlog_stores_valid_utf8_even_for_invalid_input(string $rawMessage): void + { + // 사전 조건: 입력은 실제로 invalid UTF-8 이어야 테스트가 의미 있음 + $this->assertFalse( + mb_check_encoding($rawMessage, 'UTF-8'), + '테스트 입력 자체가 invalid UTF-8 이어야 한다 (표본 점검).' + ); + + $written = addLog($rawMessage); + $this->assertTrue($written, 'addLog 는 성공적으로 로그를 기록해야 한다.'); + + $logs = getInstallationLogs(); + $this->assertNotEmpty($logs, '기록한 로그가 조회되어야 한다.'); + + $message = $logs[count($logs) - 1]['message'] ?? ''; + + // 핵심 계약: 저장된 로그 메시지는 항상 유효 UTF-8 이어야 한다. + $this->assertTrue( + mb_check_encoding($message, 'UTF-8'), + '저장된 로그 메시지는 항상 유효 UTF-8 이어야 한다 (invalid 바이트는 scrub 대체). '. + '실패 시: composer 출력의 깨진 바이트가 로그에 raw 저장되어 폴링 응답 json_encode 가 무력화됨.' + ); + } + + #[Test] + #[DataProvider('invalidUtf8Provider')] + public function polling_response_json_encode_never_returns_false(string $rawMessage): void + { + addLog($rawMessage); + + $logs = getInstallationLogs(); + + // 폴링 응답(state-management.php getState) 이 만드는 것과 동일한 구조를 모사. + $response = [ + 'status' => 'running', + 'logs' => $logs, + 'log_total' => getInstallationLogCount(), + ]; + + // 근본 수정 후: logs 가 유효 UTF-8 이므로 substitute 플래그 없이도 false 가 아니어야 한다. + $encoded = json_encode($response, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); + + $this->assertNotFalse( + $encoded, + 'invalid UTF-8 로그가 섞여도 폴링 응답 json_encode 는 false 를 반환하면 안 된다 '. + '(false → echo 빈 본문 → 프론트 "Unexpected end of JSON input").' + ); + $this->assertIsString($encoded); + $this->assertNotSame('', $encoded, '인코딩 결과 본문이 비어 있으면 안 된다.'); + } + + #[Test] + public function addlog_preserves_valid_utf8_korean_unchanged(): void + { + // 정규화가 정상 UTF-8 한글을 훼손하지 않아야 한다 (회귀 방지). + // 첫 로그 줄은 UTF-8 BOM 이 붙어 getInstallationLogs 의 [timestamp] 파싱에 간섭하므로 + // 워밍업 로그를 먼저 기록해 검증 대상 줄을 두 번째 이후로 밀어낸다. + addLog('warmup'); + + $korean = 'Composer 의존성 설치를 시작합니다.'; + + addLog($korean); + $logs = getInstallationLogs(); + $message = $logs[count($logs) - 1]['message'] ?? ''; + + $this->assertSame($korean, $message, '정상 UTF-8 한글은 scrub 후에도 변형되지 않아야 한다.'); + } + + /** + * SSE 이벤트 스트림도 invalid UTF-8 로그로 인해 빈 data 라인을 송출하면 안 된다 (인코딩 계약). + * + * 폴링 모드로 제보됐으나(#62), 근본 원인(외부 유래 invalid UTF-8 → json_encode false)은 + * SSE 모드에도 동일하게 존재한다. SseEmitter::emit 의 json_encode 가 false 를 반환하면 + * "data: \n\n" 이 송출되어 프론트 EventSource 의 JSON.parse(e.data) 가 throw → 로그 이벤트 유실. + * + * SseEmitter::emit 은 내부에서 즉시 flush() 하므로 ob_get_clean() 으로 출력을 캡처할 수 없다 + * (SSE 스트리밍 설계상 의도된 동작). 따라서 emit 이 사용하는 인코딩 계약을 직접 검증한다: + * JSON_INVALID_UTF8_SUBSTITUTE 를 적용하면 invalid UTF-8 이 섞여도 false 가 아니어야 한다. + */ + #[Test] + #[DataProvider('invalidUtf8Provider')] + public function sse_emit_encoding_contract_never_returns_false_for_invalid_utf8(string $rawMessage): void + { + // emit 이 실제 사용하는 것과 동일한 인코딩 호출. + $payload = json_encode( + ['message' => $rawMessage], + JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE + ); + + $this->assertNotFalse( + $payload, + 'SSE data 인코딩은 invalid UTF-8 로그가 섞여도 false 를 반환하면 안 된다 '. + '(false → "data: \n\n" → 프론트 JSON.parse throw → 로그 이벤트 유실).' + ); + $this->assertIsString($payload); + + // 프론트 JSON.parse(e.data) 와 동일하게 파싱 가능해야 한다. + $decoded = json_decode($payload, true); + $this->assertSame(JSON_ERROR_NONE, json_last_error()); + $this->assertIsArray($decoded); + } + + /** + * progress-emitter.php 의 SseEmitter::emit 이 JSON_INVALID_UTF8_SUBSTITUTE 를 적용하는지 + * 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식). + */ + #[Test] + public function sse_emitter_source_uses_invalid_utf8_substitute_flag(): void + { + $source = (string) file_get_contents( + dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php' + ); + + // SseEmitter 클래스 본문 추출 + $classStart = strpos($source, 'class SseEmitter'); + $this->assertNotFalse($classStart, 'SseEmitter 클래스를 찾을 수 없습니다.'); + $classEnd = strpos($source, 'class NullEmitter', $classStart); + $sseBody = substr($source, $classStart, $classEnd !== false ? $classEnd - $classStart : null); + + $this->assertStringContainsString( + 'JSON_INVALID_UTF8_SUBSTITUTE', + $sseBody, + 'SseEmitter::emit 의 json_encode 는 JSON_INVALID_UTF8_SUBSTITUTE 를 적용해 '. + 'invalid UTF-8 로그로 인한 빈 SSE data 라인 송출을 차단해야 한다.' + ); + } +} diff --git a/tests/Unit/Installer/DeleteDirectoryTest.php b/tests/Unit/Installer/DeleteDirectoryTest.php index 0eb14e74..b67b0f08 100644 --- a/tests/Unit/Installer/DeleteDirectoryTest.php +++ b/tests/Unit/Installer/DeleteDirectoryTest.php @@ -43,12 +43,17 @@ class DeleteDirectoryTest extends TestCase define('INSTALLER_BASE_URL', '/install'); } - // 인스톨러 함수 로드 (pure PHPUnit TestCase 라서 Laravel base_path() 사용 불가 — BASE_PATH 상수 사용) - require_once BASE_PATH . '/public/install/includes/functions.php'; - require_once BASE_PATH . '/public/install/api/rollback-functions.php'; + // 인스톨러 함수 로드 (pure PHPUnit TestCase 라서 Laravel base_path() 사용 불가). + // 프로젝트 루트 절대경로(__DIR__ 기준)로 로드한다 — 다른 인스톨러 테스트 + // (#[RunClassInSeparateProcess] + temp BASE_PATH)가 메인 프로세스로 BASE_PATH 를 + // 누수시켜도 그 temp 경로에는 public/install 이 없어 require 가 깨지므로, 누수된 + // BASE_PATH 값과 무관하게 항상 실제 프로젝트 파일을 참조한다. + $installerRoot = dirname(__DIR__, 3); + require_once $installerRoot.'/public/install/includes/functions.php'; + require_once $installerRoot.'/public/install/api/rollback-functions.php'; // 임시 디렉토리 생성 - $this->tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'g7_test_delete_' . uniqid(); + $this->tempDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'g7_test_delete_'.uniqid(); mkdir($this->tempDir, 0755, true); } @@ -67,10 +72,10 @@ class DeleteDirectoryTest extends TestCase public function test_preserves_gitkeep_file(): void { // Given: 디렉토리에 .gitkeep과 일반 파일이 있음 - file_put_contents($this->tempDir . '/.gitkeep', ''); - file_put_contents($this->tempDir . '/some_file.txt', 'test'); - mkdir($this->tempDir . '/subdir', 0755); - file_put_contents($this->tempDir . '/subdir/nested.txt', 'test'); + file_put_contents($this->tempDir.'/.gitkeep', ''); + file_put_contents($this->tempDir.'/some_file.txt', 'test'); + mkdir($this->tempDir.'/subdir', 0755); + file_put_contents($this->tempDir.'/subdir/nested.txt', 'test'); // When: deleteDirectory 호출 (기본: removeDir=false) $result = deleteDirectory($this->tempDir); @@ -78,9 +83,9 @@ class DeleteDirectoryTest extends TestCase // Then: .gitkeep은 보존, 나머지는 삭제, 디렉토리 자체는 유지 $this->assertTrue($result); $this->assertDirectoryExists($this->tempDir); - $this->assertFileExists($this->tempDir . '/.gitkeep'); - $this->assertFileDoesNotExist($this->tempDir . '/some_file.txt'); - $this->assertDirectoryDoesNotExist($this->tempDir . '/subdir'); + $this->assertFileExists($this->tempDir.'/.gitkeep'); + $this->assertFileDoesNotExist($this->tempDir.'/some_file.txt'); + $this->assertDirectoryDoesNotExist($this->tempDir.'/subdir'); } /** @@ -89,8 +94,8 @@ class DeleteDirectoryTest extends TestCase public function test_preserves_gitignore_file(): void { // Given: 디렉토리에 .gitignore와 일반 파일이 있음 - file_put_contents($this->tempDir . '/.gitignore', '*'); - file_put_contents($this->tempDir . '/data.log', 'log'); + file_put_contents($this->tempDir.'/.gitignore', '*'); + file_put_contents($this->tempDir.'/data.log', 'log'); // When $result = deleteDirectory($this->tempDir); @@ -98,8 +103,8 @@ class DeleteDirectoryTest extends TestCase // Then $this->assertTrue($result); $this->assertDirectoryExists($this->tempDir); - $this->assertFileExists($this->tempDir . '/.gitignore'); - $this->assertFileDoesNotExist($this->tempDir . '/data.log'); + $this->assertFileExists($this->tempDir.'/.gitignore'); + $this->assertFileDoesNotExist($this->tempDir.'/data.log'); } /** @@ -108,8 +113,8 @@ class DeleteDirectoryTest extends TestCase public function test_keeps_directory_when_no_preserve_files_and_remove_dir_false(): void { // Given: 보호 파일 없이 일반 파일만 있음 - file_put_contents($this->tempDir . '/file1.txt', 'test'); - file_put_contents($this->tempDir . '/file2.txt', 'test'); + file_put_contents($this->tempDir.'/file1.txt', 'test'); + file_put_contents($this->tempDir.'/file2.txt', 'test'); // When: removeDir=false (기본값) $result = deleteDirectory($this->tempDir); @@ -117,8 +122,8 @@ class DeleteDirectoryTest extends TestCase // Then: 디렉토리 자체는 유지, 내용물은 삭제 $this->assertTrue($result); $this->assertDirectoryExists($this->tempDir); - $this->assertFileDoesNotExist($this->tempDir . '/file1.txt'); - $this->assertFileDoesNotExist($this->tempDir . '/file2.txt'); + $this->assertFileDoesNotExist($this->tempDir.'/file1.txt'); + $this->assertFileDoesNotExist($this->tempDir.'/file2.txt'); } /** @@ -127,8 +132,8 @@ class DeleteDirectoryTest extends TestCase public function test_keeps_directory_when_preserve_files_exist_even_with_remove_dir(): void { // Given: .gitkeep이 있음 - file_put_contents($this->tempDir . '/.gitkeep', ''); - file_put_contents($this->tempDir . '/other.txt', 'test'); + file_put_contents($this->tempDir.'/.gitkeep', ''); + file_put_contents($this->tempDir.'/other.txt', 'test'); // When: removeDir=true여도 보호 파일이 있으면 디렉토리 유지 $result = deleteDirectory($this->tempDir, true); @@ -136,8 +141,8 @@ class DeleteDirectoryTest extends TestCase // Then $this->assertTrue($result); $this->assertDirectoryExists($this->tempDir); - $this->assertFileExists($this->tempDir . '/.gitkeep'); - $this->assertFileDoesNotExist($this->tempDir . '/other.txt'); + $this->assertFileExists($this->tempDir.'/.gitkeep'); + $this->assertFileDoesNotExist($this->tempDir.'/other.txt'); } /** @@ -146,7 +151,7 @@ class DeleteDirectoryTest extends TestCase public function test_removes_directory_when_no_preserve_files_and_remove_dir_true(): void { // Given: 보호 파일 없음 - file_put_contents($this->tempDir . '/file.txt', 'test'); + file_put_contents($this->tempDir.'/file.txt', 'test'); // When $result = deleteDirectory($this->tempDir, true); @@ -161,10 +166,10 @@ class DeleteDirectoryTest extends TestCase public function test_handles_nested_directories(): void { // Given: vendor 디렉토리 구조 시뮬레이션 - file_put_contents($this->tempDir . '/.gitkeep', ''); - mkdir($this->tempDir . '/laravel/framework/src', 0755, true); - file_put_contents($this->tempDir . '/laravel/framework/src/Application.php', 'tempDir . '/autoload.php', 'tempDir.'/.gitkeep', ''); + mkdir($this->tempDir.'/laravel/framework/src', 0755, true); + file_put_contents($this->tempDir.'/laravel/framework/src/Application.php', 'tempDir.'/autoload.php', 'tempDir); @@ -172,9 +177,9 @@ class DeleteDirectoryTest extends TestCase // Then: .gitkeep만 보존, 나머지 전부 삭제 $this->assertTrue($result); $this->assertDirectoryExists($this->tempDir); - $this->assertFileExists($this->tempDir . '/.gitkeep'); - $this->assertFileDoesNotExist($this->tempDir . '/autoload.php'); - $this->assertDirectoryDoesNotExist($this->tempDir . '/laravel'); + $this->assertFileExists($this->tempDir.'/.gitkeep'); + $this->assertFileDoesNotExist($this->tempDir.'/autoload.php'); + $this->assertDirectoryDoesNotExist($this->tempDir.'/laravel'); } /** @@ -195,24 +200,24 @@ class DeleteDirectoryTest extends TestCase */ public function test_returns_false_for_nonexistent_directory(): void { - $result = deleteDirectory($this->tempDir . '/nonexistent'); + $result = deleteDirectory($this->tempDir.'/nonexistent'); $this->assertFalse($result); } /** * 임시 디렉토리를 완전히 정리합니다 (테스트용). * - * @param string $dir 정리할 디렉토리 경로 + * @param string $dir 정리할 디렉토리 경로 */ private function cleanupTempDir(string $dir): void { - if (!is_dir($dir)) { + if (! is_dir($dir)) { return; } $items = array_diff(scandir($dir), ['.', '..']); foreach ($items as $item) { - $path = $dir . DIRECTORY_SEPARATOR . $item; + $path = $dir.DIRECTORY_SEPARATOR.$item; if (is_dir($path)) { $this->cleanupTempDir($path); } else { diff --git a/tests/Unit/Installer/InstallerWindowsCommandsTest.php b/tests/Unit/Installer/InstallerWindowsCommandsTest.php index 95cb688b..7519ac06 100644 --- a/tests/Unit/Installer/InstallerWindowsCommandsTest.php +++ b/tests/Unit/Installer/InstallerWindowsCommandsTest.php @@ -11,6 +11,19 @@ use PHPUnit\Framework\TestCase; */ class InstallerWindowsCommandsTest extends TestCase { + /** + * 프로젝트 루트 절대경로 (BASE_PATH 상수와 독립). + * + * 다른 인스톨러 테스트(#[RunClassInSeparateProcess] + temp BASE_PATH)가 메인 + * 프로세스로 BASE_PATH 를 누수시키면, 그 temp 경로 하위에는 public/install 이 없어 + * require 가 깨진다. 본 클래스는 __DIR__ 기준 절대경로로 인스톨러 파일을 로드해 + * 누수된 BASE_PATH 값과 무관하게 항상 실제 프로젝트 파일을 참조한다. + */ + private function installerRoot(): string + { + return dirname(__DIR__, 3); + } + /** * 테스트 전 인스톨러 설정 및 함수 파일을 로드합니다. */ @@ -40,8 +53,8 @@ class InstallerWindowsCommandsTest extends TestCase define('INSTALLER_BASE_URL', '/install'); } - // functions.php 로드 (한 번만) — base_path() 대신 BASE_PATH 상수 직접 사용 - require_once BASE_PATH . '/public/install/includes/functions.php'; + // functions.php 로드 (한 번만) — 누수된 BASE_PATH 와 무관하게 프로젝트 루트 절대경로 사용 + require_once $this->installerRoot().'/public/install/includes/functions.php'; } /** @@ -165,8 +178,8 @@ class InstallerWindowsCommandsTest extends TestCase */ public function test_translation_keys_exist(): void { - $koTranslations = require BASE_PATH . '/public/install/lang/ko.php'; - $enTranslations = require BASE_PATH . '/public/install/lang/en.php'; + $koTranslations = require $this->installerRoot().'/public/install/lang/ko.php'; + $enTranslations = require $this->installerRoot().'/public/install/lang/en.php'; $this->assertArrayHasKey('permission_windows_hint', $koTranslations, 'ko.php에 permission_windows_hint 키가 없습니다'); $this->assertArrayHasKey('permission_windows_hint', $enTranslations, 'en.php에 permission_windows_hint 키가 없습니다'); @@ -179,8 +192,8 @@ class InstallerWindowsCommandsTest extends TestCase */ public function test_directory_create_guide_translation_keys_exist(): void { - $koTranslations = require BASE_PATH . '/public/install/lang/ko.php'; - $enTranslations = require BASE_PATH . '/public/install/lang/en.php'; + $koTranslations = require $this->installerRoot().'/public/install/lang/ko.php'; + $enTranslations = require $this->installerRoot().'/public/install/lang/en.php'; $this->assertArrayHasKey('directory_create_guide', $koTranslations, 'ko.php에 directory_create_guide 키가 없습니다'); $this->assertArrayHasKey('directory_create_guide', $enTranslations, 'en.php에 directory_create_guide 키가 없습니다');