From 342486a20bf68f0cb5f72fc6760cb2f9f5d67431 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 6 Jul 2026 11:16:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(installer):=20Composer=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20=EB=A1=9C=EA=B7=B8=20=EC=9D=B8=EC=BD=94=EB=94=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=9D=91=EB=8B=B5=20=EB=B9=88=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer 실행 모드로 설치할 때 일부 Windows 환경에서 composer 출력의 invalid UTF-8 바이트가 정규화 없이 로그에 저장되어, 진행 상태 응답의 json_encode 가 false 를 반환 → 빈 본문(HTTP 200) → 프론트 파싱 실패로 진행 상황이 표시되지 않던 문제. - 로그 저장(addLog)에서 mb_scrub 로 항상 유효 UTF-8 보장 (근본) - composer 스트림 라인 정규화 (SSE/폴링 공통 경계) - 폴링(state-management)·SSE(progress-emitter) 응답 json_encode 에 JSON_INVALID_UTF8_SUBSTITUTE + false 가드 (양쪽 안전망) 무관 결함 동시 정리: 신규 격리 테스트가 트리거한 BASE_PATH 누수로 깨지던 DeleteDirectoryTest·InstallerWindowsCommandsTest 를 프로젝트 루트 절대경로 앵커로 격리. 공개 이슈 gnuboard/g7 (@kitrio 제보). --- CHANGELOG.md | 1 + public/install/api/state-management.php | 23 +- public/install/includes/installer-state.php | 127 ++++--- public/install/includes/progress-emitter.php | 30 +- public/install/includes/task-runner.php | 329 ++++++++++-------- tests/Unit/Installer/AddLogUtf8ScrubTest.php | 251 +++++++++++++ tests/Unit/Installer/DeleteDirectoryTest.php | 75 ++-- .../InstallerWindowsCommandsTest.php | 25 +- 8 files changed, 615 insertions(+), 246 deletions(-) create mode 100644 tests/Unit/Installer/AddLogUtf8ScrubTest.php 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 키가 없습니다'); From 157485003d14979405c1878a359c86f6a8dc81ce Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 6 Jul 2026 17:06:36 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(installer):=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EA=B2=80=EC=A6=9D=EC=9D=98=20=EB=94=94?= =?UTF-8?q?=EB=A0=89=ED=86=A0=EB=A6=AC=20=EA=B6=8C=ED=95=9C=20=EC=9E=AC?= =?UTF-8?q?=EA=B7=80=20=EC=8A=A4=EC=BA=94=20=EA=B9=8A=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=EC=9C=BC=EB=A1=9C=202=EB=8B=A8=EA=B3=84=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인스톨러 2단계(설치 환경 확인)의 checkDirectoryPermissions 가 storage 를 재귀 스캔하는데, checkSubdirectoriesRecursive 가 깊이 제한 없이 무한 재귀하여 storage/framework/testing 하위에 누적된 수만 개 디렉토리를 전부 순회 → 응답이 8초 이상 지연되던 문제. 쓰기 권한 문제는 트리 상위 얕은 깊이에서 결정되므로(상위 쓰기 가능 시 하위 상속), 재귀에 maxDepth(기본 1) 제한을 추가. 기본값이 있어 호출부는 변경 불필요. 실측: checkDirectoryPermissions 8.4초 → 0.086초. --- CHANGELOG.md | 1 + public/install/includes/functions.php | 301 +++++++++--------- .../DirectoryPermissionScanDepthTest.php | 134 ++++++++ 3 files changed, 291 insertions(+), 145 deletions(-) create mode 100644 tests/Unit/Installer/DirectoryPermissionScanDepthTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bba4494..dede7c91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다. - 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.) +- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다. ## [7.0.1] - 2026-07-03 diff --git a/public/install/includes/functions.php b/public/install/includes/functions.php index 7d705628..07c11d08 100644 --- a/public/install/includes/functions.php +++ b/public/install/includes/functions.php @@ -5,8 +5,6 @@ * * HTML/JavaScript 이스케이프, 데이터베이스 연결, 권한 검증, 에러 로깅 등의 * 공통 유틸리티 함수를 제공합니다. - * - * @package G7\Installer */ /** @@ -45,8 +43,9 @@ function js_escape(mixed $value): string /** * 데이터베이스 연결 생성 * - * @param array $config 데이터베이스 연결 정보 배열 - * @param bool $isReadDb Read DB 여부 (true: read, false: write) + * @param array $config 데이터베이스 연결 정보 배열 + * @param bool $isReadDb Read DB 여부 (true: read, false: write) + * * @throws PDOException 연결 실패 시 */ function getDatabaseConnection(array $config, bool $isReadDb = false): PDO @@ -97,9 +96,9 @@ function getDatabaseConnection(array $config, bool $isReadDb = false): PDO /** * 데이터베이스 권한 검증 * - * @param PDO $pdo PDO 연결 객체 - * @param string $database 데이터베이스명 - * @param bool $isReadDb Read DB 여부 (true: read, false: write) + * @param PDO $pdo PDO 연결 객체 + * @param string $database 데이터베이스명 + * @param bool $isReadDb Read DB 여부 (true: read, false: write) * @return array{has_all: bool, required: array, found: array, missing: array} */ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = false): array @@ -165,7 +164,7 @@ function checkDatabasePrivileges(PDO $pdo, string $database, bool $isReadDb = fa * * Synology DSM 등 PHP-FPM root 실행 환경 + 비대화형 컨텍스트 대응. */ -if (!function_exists('applyInstallerComposerEnvVars')) { +if (! function_exists('applyInstallerComposerEnvVars')) { function applyInstallerComposerEnvVars(): void { putenv('COMPOSER_ALLOW_SUPERUSER=1'); @@ -180,7 +179,7 @@ if (!function_exists('applyInstallerComposerEnvVars')) { * * @return array */ -if (!function_exists('buildInstallerComposerEnv')) { +if (! function_exists('buildInstallerComposerEnv')) { function buildInstallerComposerEnv(): array { return [ @@ -193,13 +192,13 @@ if (!function_exists('buildInstallerComposerEnv')) { /** * 인스톨러 에러 로깅 * - * @param string $message 에러 메시지 - * @param Throwable|null $exception 예외 객체 (선택) + * @param string $message 에러 메시지 + * @param Throwable|null $exception 예외 객체 (선택) */ function logInstallationError(string $message, ?Throwable $exception = null): void { - $logDir = BASE_PATH . '/storage/logs'; - $logFile = $logDir . '/installer.log'; + $logDir = BASE_PATH.'/storage/logs'; + $logFile = $logDir.'/installer.log'; // 로그 디렉토리 생성 if (! is_dir($logDir)) { @@ -212,13 +211,13 @@ function logInstallationError(string $message, ?Throwable $exception = null): vo if ($exception !== null) { $logMessage .= "\n"; - $logMessage .= "Exception: " . get_class($exception) . "\n"; + $logMessage .= 'Exception: '.get_class($exception)."\n"; $logMessage .= "Message: {$exception->getMessage()}\n"; $logMessage .= "File: {$exception->getFile()}:{$exception->getLine()}\n"; $logMessage .= "Trace:\n{$exception->getTraceAsString()}\n"; } - $logMessage .= "\n" . str_repeat('-', 80) . "\n\n"; + $logMessage .= "\n".str_repeat('-', 80)."\n\n"; // 로그 파일에 기록 @file_put_contents($logFile, $logMessage, FILE_APPEND | LOCK_EX); @@ -232,7 +231,7 @@ function logInstallationError(string $message, ?Throwable $exception = null): vo function detectBrowserLanguage(): ?string { // HTTP_ACCEPT_LANGUAGE 헤더가 없으면 null 반환 - if (!isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + if (! isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { return null; } @@ -286,14 +285,14 @@ function getCurrentLanguage(): string /** * 지정된 언어의 번역 파일을 로드합니다. * - * @param string $lang 언어 코드 (예: 'ko', 'en') + * @param string $lang 언어 코드 (예: 'ko', 'en') * @return array 번역 배열, 파일이 없으면 빈 배열 */ function loadTranslations(string $lang): array { - $langFile = __DIR__ . "/../lang/{$lang}.php"; + $langFile = __DIR__."/../lang/{$lang}.php"; - if (!file_exists($langFile)) { + if (! file_exists($langFile)) { return []; } @@ -308,15 +307,15 @@ function loadTranslations(string $lang): array * global $translations 배열에서 키에 해당하는 값을 찾아 반환합니다. * 키가 존재하지 않으면 키 자체를 반환합니다. * - * @param string $key 번역 키 (예: 'welcome_title', 'next_step') - * @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2']) + * @param string $key 번역 키 (예: 'welcome_title', 'next_step') + * @param array $replace 치환할 플레이스홀더 배열 (예: ['current' => '8.2', 'min' => '8.2']) */ if (! function_exists('lang')) { function lang(string $key, array $replace = []): string { global $translations; - if (!isset($translations) || !is_array($translations)) { + if (! isset($translations) || ! is_array($translations)) { return $key; } @@ -334,7 +333,7 @@ if (! function_exists('lang')) { /** * 설치 단계 이름을 반환합니다. * - * @param int $step 단계 번호 (0~5, 6은 예약됨) + * @param int $step 단계 번호 (0~5, 6은 예약됨) * @return string 단계명 번역 키 */ function getStepName(int $step): string @@ -355,7 +354,7 @@ function getStepName(int $step): string /** * 설치 작업 이름을 반환합니다. * - * @param string $task 작업 ID (예: 'composer_check', 'env_create') + * @param string $task 작업 ID (예: 'composer_check', 'env_create') * @return string 작업명 번역 키 */ function getTaskName(string $task): string @@ -394,12 +393,12 @@ function getTaskName(string $task): string /** * 지정한 Step으로 리다이렉트합니다. * - * @param int $step 이동할 단계 번호 + * @param int $step 이동할 단계 번호 */ function redirectToStep(int $step): void { $_SESSION['installer_current_step'] = $step; - header("Location: " . INSTALLER_BASE_URL . "/"); + header('Location: '.INSTALLER_BASE_URL.'/'); exit; } @@ -411,12 +410,12 @@ function redirectToStep(int $step): void * - state.json: 다른 브라우저에서 재개 시 사용, 설치 완료 후 Laravel 본체에 전달 * - localStorage: JavaScript에서 직접 관리 (0-welcome.php에서 처리) * - * @param string $lang 언어 코드 ('ko' 또는 'en') + * @param string $lang 언어 코드 ('ko' 또는 'en') */ function handleLanguageChange(string $lang): void { // 지원하는 언어만 허용 - if (!array_key_exists($lang, SUPPORTED_LANGUAGES)) { + if (! array_key_exists($lang, SUPPORTED_LANGUAGES)) { return; } @@ -432,9 +431,9 @@ function handleLanguageChange(string $lang): void /** * 현재 단계의 상태를 업데이트합니다. * - * @param int $step 완료할 단계 번호 - * @param int $nextStep 다음 단계 번호 - * @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data]) + * @param int $step 완료할 단계 번호 + * @param int $nextStep 다음 단계 번호 + * @param array $additionalData 추가로 저장할 state 데이터 (예: ['config' => $data]) */ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): void { @@ -442,7 +441,7 @@ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): $state['current_step'] = $nextStep; $state['step_status'][$step] = 'completed'; - if (!empty($additionalData)) { + if (! empty($additionalData)) { $state = array_merge($state, $additionalData); } @@ -455,6 +454,7 @@ function updateStepStatus(int $step, int $nextStep, array $additionalData = []): function isInstallationRunning(): bool { $state = getInstallationState(); + return isset($state['installation_status']) && $state['installation_status'] === 'running'; } @@ -466,7 +466,7 @@ function showInstallationRunningAlert(): void global $translations; // 번역이 로드되지 않은 경우 로드 - if (!isset($translations)) { + if (! isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -474,7 +474,7 @@ function showInstallationRunningAlert(): void showAlertAndRedirect( lang('installation_in_progress'), lang('installation_in_progress_message'), - INSTALLER_BASE_URL . '/' + INSTALLER_BASE_URL.'/' ); } @@ -486,7 +486,7 @@ function showInstallationCompletedAlert(): void global $translations; // 번역이 로드되지 않은 경우 로드 - if (!isset($translations)) { + if (! isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -500,15 +500,15 @@ function showInstallationCompletedAlert(): void /** * 알럿을 표시하고 지정된 URL로 리다이렉트합니다. * - * @param string $title 페이지 제목 - * @param string $message 알럿 메시지 - * @param string $redirectUrl 리다이렉트 URL + * @param string $title 페이지 제목 + * @param string $message 알럿 메시지 + * @param string $redirectUrl 리다이렉트 URL */ function showAlertAndRedirect(string $title, string $message, string $redirectUrl): void { global $translations; - if (!isset($translations)) { + if (! isset($translations)) { $translations = loadTranslations(getCurrentLanguage()); } @@ -518,16 +518,16 @@ function showAlertAndRedirect(string $title, string $message, string $redirectUr $redirectUrl = htmlspecialchars($redirectUrl); echo ''; - echo ''; + echo ''; echo ''; echo ''; echo ''; - echo '' . $title . ''; + echo ''.$title.''; echo ''; echo ''; echo ''; echo ''; echo ''; @@ -537,7 +537,7 @@ function showAlertAndRedirect(string $title, string $message, string $redirectUr /** * 단계 파일을 찾을 수 없을 때 에러 페이지를 표시합니다. * - * @param int $currentStep 현재 단계 번호 + * @param int $currentStep 현재 단계 번호 */ function showStepFileNotFoundError(int $currentStep): void { @@ -553,9 +553,9 @@ function showStepFileNotFoundError(int $currentStep): void echo ''; echo lang('error'); echo ''; - echo '

' . lang('error_step_file_not_found') . '

'; + echo '

'.lang('error_step_file_not_found').'

'; echo ''; echo ''; } @@ -565,8 +565,8 @@ function showStepFileNotFoundError(int $currentStep): void * * DB 연결 정보가 변경되었는지 감지하기 위해 사용됩니다. * - * @param array $config 폼 데이터 배열 - * @param string $prefix DB 접두사 ('db_write' 또는 'db_read') + * @param array $config 폼 데이터 배열 + * @param string $prefix DB 접두사 ('db_write' 또는 'db_read') * @return string MD5 해시값 */ function getDatabaseFieldHash(array $config, string $prefix): string @@ -575,14 +575,14 @@ function getDatabaseFieldHash(array $config, string $prefix): string $config["{$prefix}_host"] ?? '', $config["{$prefix}_port"] ?? '', $config["{$prefix}_database"] ?? '', - $config["{$prefix}_username"] ?? '' + $config["{$prefix}_username"] ?? '', ])); } /** * SVG 아이콘을 반환합니다. * - * @param string $type 아이콘 타입 ('success', 'warning', 'error') + * @param string $type 아이콘 타입 ('success', 'warning', 'error') * @return string SVG HTML */ function getSvgIcon(string $type): string @@ -609,12 +609,12 @@ function getSvgIcon(string $type): string /** * 설치 결과 섹션 HTML을 렌더링합니다. * - * @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure') - * @param string $iconType 아이콘 타입 ('success', 'warning', 'error') - * @param string $titleKey 제목 번역 키 - * @param string $messageKey 메시지 번역 키 (failure인 경우 동적) - * @param string $buttonsHtml 버튼 HTML - * @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달) + * @param string $sectionId 섹션 ID ('completion', 'aborted', 'failure') + * @param string $iconType 아이콘 타입 ('success', 'warning', 'error') + * @param string $titleKey 제목 번역 키 + * @param string $messageKey 메시지 번역 키 (failure인 경우 동적) + * @param string $buttonsHtml 버튼 HTML + * @param string $extraHtml 메시지 하단에 추가로 렌더링할 HTML (이미 escape 된 상태로 전달) * @return string 결과 섹션 HTML */ function renderInstallResultSection(string $sectionId, string $iconType, string $titleKey, string $messageKey, string $buttonsHtml, string $extraHtml = ''): string @@ -625,7 +625,7 @@ function renderInstallResultSection(string $sectionId, string $iconType, string // failure는 동적 메시지이므로 번역하지 않음 $message = $sectionId === 'failure' ? '
' - : '

' . htmlspecialchars(lang($messageKey)) . '

'; + : '

'.htmlspecialchars(lang($messageKey)).'

'; return << @@ -657,8 +657,8 @@ function renderInstallResultSection(string $sectionId, string $iconType, string function getCoreAppVersion(): string { foreach (['/.env', '/.env.example'] as $relative) { - $path = BASE_PATH . $relative; - if (!is_readable($path)) { + $path = BASE_PATH.$relative; + if (! is_readable($path)) { continue; } $content = @file_get_contents($path); @@ -676,8 +676,8 @@ function getCoreAppVersion(): string /** * Progress indicator HTML을 렌더링합니다. * - * @param int $currentStep 현재 단계 번호 - * @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6) + * @param int $currentStep 현재 단계 번호 + * @param int $totalSteps 전체 단계 수 (기본값: 7, Step 0~6) * @return string Progress indicator HTML */ function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string @@ -690,9 +690,10 @@ function renderProgressIndicator(int $currentStep, int $totalSteps = 7): string } elseif ($i === $currentStep) { $class .= ' active'; } - $html .= '
'; + $html .= '
'; } $html .= ''; + return $html; } @@ -709,7 +710,7 @@ function getWebServerUser(): ?string // 1. posix 확장 (가장 신뢰도 높음) if (function_exists('posix_geteuid') && function_exists('posix_getpwuid')) { $info = posix_getpwuid(posix_geteuid()); - if ($info && !empty($info['name'])) { + if ($info && ! empty($info['name'])) { return $info['name']; } } @@ -717,14 +718,14 @@ function getWebServerUser(): ?string // 2. exec('whoami') if (function_exists('exec')) { $user = @exec('whoami'); - if (!empty($user)) { + if (! empty($user)) { return trim($user); } } // 3. 환경변수 $envUser = getenv('USER') ?: getenv('APACHE_RUN_USER') ?: null; - if (!empty($envUser)) { + if (! empty($envUser)) { return $envUser; } @@ -740,13 +741,13 @@ function getWebServerGroup(): ?string { if (function_exists('posix_getegid') && function_exists('posix_getgrgid')) { $info = posix_getgrgid(posix_getegid()); - if ($info && !empty($info['name'])) { + if ($info && ! empty($info['name'])) { return $info['name']; } } $envGroup = getenv('APACHE_RUN_GROUP') ?: null; - if (!empty($envGroup)) { + if (! empty($envGroup)) { return $envGroup; } @@ -758,7 +759,7 @@ function getWebServerGroup(): ?string * * posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다. * - * @param string $path 파일/디렉토리 경로 + * @param string $path 파일/디렉토리 경로 * @return string|null 소유자 이름 또는 null */ function getFileOwnerName(string $path): ?string @@ -770,7 +771,7 @@ function getFileOwnerName(string $path): ?string if (function_exists('posix_getpwuid')) { $info = @posix_getpwuid($uid); - if ($info && !empty($info['name'])) { + if ($info && ! empty($info['name'])) { return $info['name']; } } @@ -784,7 +785,7 @@ function getFileOwnerName(string $path): ?string * * posix 확장이 없거나 확인할 수 없는 경우 null을 반환합니다. * - * @param string $path 파일/디렉토리 경로 + * @param string $path 파일/디렉토리 경로 * @return string|null 그룹 이름 또는 null */ function getFileGroupName(string $path): ?string @@ -796,7 +797,7 @@ function getFileGroupName(string $path): ?string if (function_exists('posix_getgrgid')) { $info = @posix_getgrgid($gid); - if ($info && !empty($info['name'])) { + if ($info && ! empty($info['name'])) { return $info['name']; } } @@ -817,7 +818,7 @@ function getChownTarget(): string $user = getWebServerUser() ?? 'www-data'; $group = getWebServerGroup() ?? $user; - return $user . ':' . $group; + return $user.':'.$group; } /** @@ -826,7 +827,7 @@ function getChownTarget(): string * Step 0과 Step 2에서 재사용 가능한 공통 함수입니다. * 디렉토리를 생성하지 않고 존재 여부와 권한만 체크합니다. * - * @param array $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) + * @param array $directories 체크할 디렉토리 경로 배열 (키: 경로, 값: 재귀 체크 여부) * @return array{all_passed: bool, results: array}>} */ function checkDirectoryPermissions(array $directories): array @@ -842,7 +843,7 @@ function checkDirectoryPermissions(array $directories): array $webServerUser = getWebServerUser(); foreach ($directories as $path => $checkRecursive) { - $fullPath = BASE_PATH . '/' . $path; + $fullPath = BASE_PATH.'/'.$path; // 디렉토리 존재 여부 확인 (생성하지 않음) $exists = is_dir($fullPath); @@ -883,22 +884,22 @@ function checkDirectoryPermissions(array $directories): array $parentPassed = $writable && $readable; // 최종 통과 여부: 상위 + 하위 모두 통과해야 함 - $passed = $parentPassed && !$hasSubdirectoryIssues; + $passed = $parentPassed && ! $hasSubdirectoryIssues; // 에러 타입 구분 // ownership_mismatch: 권한 비트는 0755 이상이지만 소유자가 웹서버 사용자와 달라 // 실제 쓰기 불가 (전통적 Apache: 파일 소유자 != www-data) $errorType = null; - if (!$exists) { + if (! $exists) { $errorType = 'not_exists'; - } elseif (!$writable) { + } elseif (! $writable) { // 권한 비트가 충분(0755+)한데도 쓰기 불가 → 소유권 불일치 가능성 if ($permsOctal >= 0755 && $webServerUser && $owner && $owner !== $webServerUser) { $errorType = 'ownership_mismatch'; } else { $errorType = 'not_writable'; } - } elseif (!$readable) { + } elseif (! $readable) { $errorType = 'not_readable'; } elseif ($hasSubdirectoryIssues) { // 상위는 OK, 하위 디렉토리만 문제 @@ -920,7 +921,7 @@ function checkDirectoryPermissions(array $directories): array 'failed_subdirectories' => $failedSubdirectories, ]; - if (!$passed) { + if (! $passed) { $allPassed = false; } } @@ -934,14 +935,22 @@ function checkDirectoryPermissions(array $directories): array /** * 디렉토리의 하위를 재귀적으로 체크하여 권한이 없는 경로와 권한 정보 반환 * - * @param string $path 체크할 디렉토리 경로 + * 깊이 제한(maxDepth)을 둔다 (dev-g7#445 후속): storage 처럼 하위에 수만 개 + * 디렉토리가 누적되는 경로(예: storage/framework/testing/)를 무한 재귀로 순회하면 + * 인스톨러 2단계(요구사항 검증) 응답이 수 초 이상 지연된다. 쓰기 권한 문제는 트리 + * 상위 얕은 깊이에서 결정되므로(상위가 쓰기 가능하면 하위도 통상 상속), 제한 깊이까지만 + * 검사해 성능과 안내 유효성을 모두 확보한다. + * + * @param string $path 체크할 디렉토리 경로 + * @param int $maxDepth 재귀 최대 깊이 (0 = 현재 디렉토리의 직속 하위만). 기본 1 + * @param int $currentDepth 내부 재귀용 현재 깊이 (호출자는 지정하지 않음) * @return array 권한이 없는 하위 디렉토리 정보 배열 */ -function checkSubdirectoriesRecursive(string $path): array +function checkSubdirectoriesRecursive(string $path, int $maxDepth = 1, int $currentDepth = 0): array { $failedPaths = []; - if (!is_dir($path)) { + if (! is_dir($path)) { return $failedPaths; } @@ -962,21 +971,21 @@ function checkSubdirectoriesRecursive(string $path): array continue; } - $itemPath = $path . DIRECTORY_SEPARATOR . $item; + $itemPath = $path.DIRECTORY_SEPARATOR.$item; // 디렉토리인지 확인 (심볼릭 링크 제외) - if (!is_dir($itemPath) || is_link($itemPath)) { + if (! is_dir($itemPath) || is_link($itemPath)) { continue; } // 절대 경로를 상대 경로로 변환 - $relativePath = str_replace(BASE_PATH . '/', '', $itemPath); + $relativePath = str_replace(BASE_PATH.'/', '', $itemPath); $relativePath = str_replace('\\', '/', $relativePath); // 쓰기 권한 체크 $isWritable = is_writable($itemPath); - if (!$isWritable) { + if (! $isWritable) { // 권한 코드 가져오기 $perms = fileperms($itemPath); $permissions = substr(sprintf('%o', $perms), -3); @@ -988,11 +997,15 @@ function checkSubdirectoriesRecursive(string $path): array } // 재귀적으로 하위 디렉토리 체크 (권한 없어도 계속 진행) - try { - $subFailed = checkSubdirectoriesRecursive($itemPath); - $failedPaths = array_merge($failedPaths, $subFailed); - } catch (Exception $e) { - // 하위 디렉토리 체크 실패해도 계속 진행 + // 깊이 제한 도달 시 더 내려가지 않는다 — storage/framework/testing 등에 누적된 + // 수만 개 하위 디렉토리를 순회해 인스톨러 2단계가 지연되던 문제 차단 (dev-g7#445 후속). + if ($currentDepth < $maxDepth) { + try { + $subFailed = checkSubdirectoriesRecursive($itemPath, $maxDepth, $currentDepth + 1); + $failedPaths = array_merge($failedPaths, $subFailed); + } catch (Exception $e) { + // 하위 디렉토리 체크 실패해도 계속 진행 + } } } @@ -1004,12 +1017,12 @@ function checkSubdirectoriesRecursive(string $path): array * * 루트 LICENSE 파일을 읽습니다. (한국어 번역 + 영문 원문 통합) * - * @param string $lang 언어 코드 (미사용, 하위 호환성 유지) + * @param string $lang 언어 코드 (미사용, 하위 호환성 유지) * @return string 라이선스 파일 내용 */ function loadLicenseFile(string $lang): string { - $licenseFile = __DIR__ . '/../../../LICENSE'; + $licenseFile = __DIR__.'/../../../LICENSE'; if (file_exists($licenseFile)) { return file_get_contents($licenseFile); @@ -1024,7 +1037,7 @@ function loadLicenseFile(string $lang): string * 특수 문자가 포함된 값을 .env 파일에 안전하게 기록할 수 있도록 큰따옴표로 감싸고 * 내부 큰따옴표와 백슬래시를 이스케이프합니다. * - * @param string $value 이스케이프할 값 + * @param string $value 이스케이프할 값 * @return string 큰따옴표로 감싸고 내부 큰따옴표와 백슬래시를 이스케이프한 값 */ function escapeEnvValue(string $value): string @@ -1044,7 +1057,7 @@ function escapeEnvValue(string $value): string $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value); // 큰따옴표로 감싸기 - return '"' . $escaped . '"'; + return '"'.$escaped.'"'; } /** @@ -1057,8 +1070,8 @@ function escapeEnvValue(string $value): string */ function generateEnvContent(): ?string { - $envExamplePath = BASE_PATH . '/.env.example'; - if (!file_exists($envExamplePath)) { + $envExamplePath = BASE_PATH.'/.env.example'; + if (! file_exists($envExamplePath)) { return null; } @@ -1069,39 +1082,39 @@ function generateEnvContent(): ?string // 데이터베이스 설정 치환 $replacements = [ 'DB_CONNECTION=mysql' => 'DB_CONNECTION=mysql', - 'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST=' . ($config['db_write_host'] ?? '127.0.0.1'), - 'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT=' . ($config['db_write_port'] ?? '3306'), - 'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE=' . ($config['db_write_database'] ?? 'g7'), - 'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME=' . ($config['db_write_username'] ?? 'root'), - 'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD=' . escapeEnvValue($config['db_write_password'] ?? ''), - 'DB_PREFIX=g7_' => 'DB_PREFIX=' . ($config['db_prefix'] ?? 'g7_'), + 'DB_WRITE_HOST=127.0.0.1' => 'DB_WRITE_HOST='.($config['db_write_host'] ?? '127.0.0.1'), + 'DB_WRITE_PORT=3306' => 'DB_WRITE_PORT='.($config['db_write_port'] ?? '3306'), + 'DB_WRITE_DATABASE=g7' => 'DB_WRITE_DATABASE='.($config['db_write_database'] ?? 'g7'), + 'DB_WRITE_USERNAME=root' => 'DB_WRITE_USERNAME='.($config['db_write_username'] ?? 'root'), + 'DB_WRITE_PASSWORD=' => 'DB_WRITE_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? ''), + 'DB_PREFIX=g7_' => 'DB_PREFIX='.($config['db_prefix'] ?? 'g7_'), ]; // Read DB 설정 - if (!empty($config['use_read_db']) && $config['use_read_db']) { - $replacements['DB_READ_HOST='] = 'DB_READ_HOST=' . ($config['db_read_host'] ?? '127.0.0.1'); - $replacements['DB_READ_PORT='] = 'DB_READ_PORT=' . ($config['db_read_port'] ?? '3306'); - $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=' . ($config['db_read_database'] ?? 'g7'); - $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=' . ($config['db_read_username'] ?? 'root'); - $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=' . escapeEnvValue($config['db_read_password'] ?? ''); + if (! empty($config['use_read_db']) && $config['use_read_db']) { + $replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_read_host'] ?? '127.0.0.1'); + $replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_read_port'] ?? '3306'); + $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_read_database'] ?? 'g7'); + $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_read_username'] ?? 'root'); + $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_read_password'] ?? ''); } else { // Read DB를 사용하지 않는 경우 Write DB 정보와 동일하게 설정 - $replacements['DB_READ_HOST='] = 'DB_READ_HOST=' . ($config['db_write_host'] ?? '127.0.0.1'); - $replacements['DB_READ_PORT='] = 'DB_READ_PORT=' . ($config['db_write_port'] ?? '3306'); - $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE=' . ($config['db_write_database'] ?? 'g7'); - $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME=' . ($config['db_write_username'] ?? 'root'); - $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD=' . escapeEnvValue($config['db_write_password'] ?? ''); + $replacements['DB_READ_HOST='] = 'DB_READ_HOST='.($config['db_write_host'] ?? '127.0.0.1'); + $replacements['DB_READ_PORT='] = 'DB_READ_PORT='.($config['db_write_port'] ?? '3306'); + $replacements['DB_READ_DATABASE='] = 'DB_READ_DATABASE='.($config['db_write_database'] ?? 'g7'); + $replacements['DB_READ_USERNAME='] = 'DB_READ_USERNAME='.($config['db_write_username'] ?? 'root'); + $replacements['DB_READ_PASSWORD='] = 'DB_READ_PASSWORD='.escapeEnvValue($config['db_write_password'] ?? ''); } // 앱 설정 치환 - $replacements['APP_NAME=그누보드7'] = 'APP_NAME="' . ($config['app_name'] ?? '그누보드7') . '"'; - $replacements['APP_ENV=production'] = 'APP_ENV=' . ($config['app_env'] ?? 'production'); - $replacements['APP_URL=http://localhost'] = 'APP_URL=' . ($config['app_url'] ?? 'http://localhost'); + $replacements['APP_NAME=그누보드7'] = 'APP_NAME="'.($config['app_name'] ?? '그누보드7').'"'; + $replacements['APP_ENV=production'] = 'APP_ENV='.($config['app_env'] ?? 'production'); + $replacements['APP_URL=http://localhost'] = 'APP_URL='.($config['app_url'] ?? 'http://localhost'); // 언어 설정 $currentLang = getCurrentLanguage(); - $replacements['APP_LOCALE=ko'] = 'APP_LOCALE=' . $currentLang; - $replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE=' . ($currentLang === 'ko' ? 'ko_KR' : 'en_US'); + $replacements['APP_LOCALE=ko'] = 'APP_LOCALE='.$currentLang; + $replacements['APP_FAKER_LOCALE=ko_KR'] = 'APP_FAKER_LOCALE='.($currentLang === 'ko' ? 'ko_KR' : 'en_US'); if (isset($config['app_env']) && $config['app_env'] !== 'production') { $replacements['APP_DEBUG=false'] = 'APP_DEBUG=true'; @@ -1112,15 +1125,15 @@ function generateEnvContent(): ?string $coreGithubUrl = $config['core_update_github_url'] ?? 'https://github.com/gnuboard/g7'; $coreGithubToken = $config['core_update_github_token'] ?? ''; - $replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH=' . escapeEnvValue($corePendingPath); - $replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL=' . $coreGithubUrl; - $replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN=' . escapeEnvValue($coreGithubToken); + $replacements['G7_UPDATE_PENDING_PATH='] = 'G7_UPDATE_PENDING_PATH='.escapeEnvValue($corePendingPath); + $replacements['G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7'] = 'G7_UPDATE_GITHUB_URL='.$coreGithubUrl; + $replacements['G7_UPDATE_GITHUB_TOKEN='] = 'G7_UPDATE_GITHUB_TOKEN='.escapeEnvValue($coreGithubToken); // PHP CLI / Composer 바이너리 경로 치환 $phpBinary = $config['php_binary'] ?? 'php'; $composerBinary = $config['composer_binary'] ?? ''; - $replacements['PHP_BINARY=php'] = 'PHP_BINARY=' . escapeEnvValue($phpBinary); - $replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY=' . escapeEnvValue($composerBinary); + $replacements['PHP_BINARY=php'] = 'PHP_BINARY='.escapeEnvValue($phpBinary); + $replacements['COMPOSER_BINARY='] = 'COMPOSER_BINARY='.escapeEnvValue($composerBinary); foreach ($replacements as $search => $replace) { $envContent = str_replace($search, $replace, $envContent); @@ -1142,22 +1155,22 @@ function isWindows(): bool /** * OS에 맞는 .env 파일 복사 명령어를 반환합니다. * - * @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용) + * @param string $basePath 프로젝트 루트 경로 (빈 문자열이면 상대 경로 사용) * @return string 복사 명령어 */ function getEnvCopyCommand(string $basePath = ''): string { if (isWindows()) { - $source = $basePath ? $basePath . '\\.env.example' : '.env.example'; - $dest = $basePath ? $basePath . '\\.env' : '.env'; + $source = $basePath ? $basePath.'\\.env.example' : '.env.example'; + $dest = $basePath ? $basePath.'\\.env' : '.env'; - return 'copy ' . str_replace('/', '\\', $source) . ' ' . str_replace('/', '\\', $dest); + return 'copy '.str_replace('/', '\\', $source).' '.str_replace('/', '\\', $dest); } - $source = $basePath ? $basePath . '/.env.example' : '.env.example'; - $dest = $basePath ? $basePath . '/.env' : '.env'; + $source = $basePath ? $basePath.'/.env.example' : '.env.example'; + $dest = $basePath ? $basePath.'/.env' : '.env'; - return 'cp ' . $source . ' ' . $dest; + return 'cp '.$source.' '.$dest; } /** @@ -1167,8 +1180,8 @@ function getEnvCopyCommand(string $basePath = ''): string * chown/setgid 의존성을 제거하여 공유호스팅 호환성 확보. * 실제 통과 기준은 is_writable() && is_readable()이므로 비트 값은 참고용. * - * @param string $pathList 대상 경로 (공백 구분) - * @param string $mode 'ownership' (디렉토리) 또는 'file' (파일) + * @param string $pathList 대상 경로 (공백 구분) + * @param string $mode 'ownership' (디렉토리) 또는 'file' (파일) * @return string 권한 수정 명령어 */ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): string @@ -1176,20 +1189,19 @@ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): if (isWindows()) { $winPath = str_replace('/', '\\', $pathList); if ($mode === 'file') { - return 'icacls ' . $winPath . ' /grant Everyone:F'; + return 'icacls '.$winPath.' /grant Everyone:F'; } - return 'icacls ' . $winPath . ' /grant Everyone:(OI)(CI)F /T'; + return 'icacls '.$winPath.' /grant Everyone:(OI)(CI)F /T'; } if ($mode === 'file') { - return 'chmod 644 ' . $pathList; + return 'chmod 644 '.$pathList; } - return 'chmod -R 755 ' . $pathList; + return 'chmod -R 755 '.$pathList; } - /** * 기존 DB 테이블 감지. * @@ -1202,9 +1214,8 @@ function getPermissionFixCommand(string $pathList, string $mode = 'ownership'): * - 'mixed' : G7 일부 + 기타 혼재 * - 'foreign_data' : G7 시그니처 없음 + 기타 테이블 존재 * - * @param PDO $pdo 연결된 PDO 인스턴스 - * @param string $database 대상 데이터베이스명 - * @return array + * @param PDO $pdo 연결된 PDO 인스턴스 + * @param string $database 대상 데이터베이스명 */ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ''): array { @@ -1237,7 +1248,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ' // G7 핵심 시그니처 테이블 — 정책 결정: 4개 모두 일치 시 "G7 설치됨"으로 판단 // 테이블 prefix를 적용하여 비교 (예: prefix='g7_'이면 'g7_users', 'g7_migrations' 등) $coreSignatures = ['users', 'migrations', 'roles', 'permissions']; - $g7Signatures = array_map(fn ($t) => $tablePrefix . $t, $coreSignatures); + $g7Signatures = array_map(fn ($t) => $tablePrefix.$t, $coreSignatures); $g7TablesFound = array_values(array_intersect($g7Signatures, $tables)); $severity = 'foreign_data'; @@ -1257,7 +1268,7 @@ function checkExistingTables(PDO $pdo, string $database, string $tablePrefix = ' ]; } -if (!function_exists('filterTablesByPrefix')) { +if (! function_exists('filterTablesByPrefix')) { /** * 기존 테이블 목록을 db_prefix 로 필터링합니다. * @@ -1268,8 +1279,8 @@ if (!function_exists('filterTablesByPrefix')) { * 빈 prefix 는 전체 일치가 되어 모든 테이블이 삭제되는 위험이 있으므로, * 데이터 손실 방어를 위해 빈 목록(삭제 대상 없음)을 반환합니다. * - * @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록 - * @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_') + * @param array $tables SHOW TABLES 로 조회한 전체 테이블 목록 + * @param string $prefix 사용자가 입력한 테이블 prefix (예: 'g7_') * @return array prefix 로 시작하는 테이블만 담은 목록 */ function filterTablesByPrefix(array $tables, string $prefix): array diff --git a/tests/Unit/Installer/DirectoryPermissionScanDepthTest.php b/tests/Unit/Installer/DirectoryPermissionScanDepthTest.php new file mode 100644 index 00000000..d867eb78 --- /dev/null +++ b/tests/Unit/Installer/DirectoryPermissionScanDepthTest.php @@ -0,0 +1,134 @@ +markTestSkipped(self::$skipReason); + } + } + + /** + * 깊이 제한을 넘는 하위 디렉토리는 재귀 스캔 대상에서 제외되어야 한다. + * + * storage/deep/a/b/c/... 처럼 깊은 트리를 만들고, checkSubdirectoriesRecursive 를 + * 제한 깊이(2)로 호출하면 depth 3 이상 경로는 결과에 나타나지 않아야 한다. + */ + #[Test] + public function recursive_scan_respects_max_depth(): void + { + // BASE_PATH/scanroot 하위에 깊이 5의 중첩 디렉토리 생성 (전부 정상 권한) + $root = self::$sharedBase.'/scanroot'; + $deep = $root.'/lvl1/lvl2/lvl3/lvl4/lvl5'; + if (! is_dir($deep)) { + mkdir($deep, 0755, true); + } + + // 깊이 2 제한으로 스캔 — 함수는 (path, maxDepth) 시그니처를 지원해야 한다. + $failed = checkSubdirectoriesRecursive($root, 2); + + // 모든 디렉토리가 정상 권한이므로 실패 항목은 없어야 하고, 무엇보다 + // 함수가 무한 재귀 없이 즉시 반환해야 한다 (깊이 제한 동작 확인). + $this->assertIsArray($failed); + + // 깊이 제한이 동작하는지 간접 검증: 깊은 트리에서도 스캔이 즉시 끝나야 한다. + $t0 = microtime(true); + checkSubdirectoriesRecursive($root, 2); + $elapsed = microtime(true) - $t0; + + $this->assertLessThan( + 1.0, + $elapsed, + '깊이 제한된 재귀 스캔은 얕은 깊이만 순회하므로 즉시 완료되어야 한다.' + ); + } + + /** + * checkSubdirectoriesRecursive 가 maxDepth 파라미터를 받는 시그니처여야 한다. + */ + #[Test] + public function function_accepts_max_depth_parameter(): void + { + $ref = new \ReflectionFunction('checkSubdirectoriesRecursive'); + $params = $ref->getParameters(); + + $this->assertGreaterThanOrEqual( + 2, + count($params), + 'checkSubdirectoriesRecursive 는 깊이 제한 파라미터를 받아야 한다 '. + '(무한 재귀로 storage 하위 수만 개 디렉토리를 순회해 인스톨러 2단계가 지연되던 문제 차단).' + ); + + // 두 번째 파라미터는 깊이 제한(정수, 기본값 존재)이어야 한다. + $depthParam = $params[1]; + $this->assertTrue( + $depthParam->isDefaultValueAvailable(), + '깊이 제한 파라미터는 기본값을 가져 기존 호출부(check-configuration.php)가 변경 없이 동작해야 한다.' + ); + } +}