fix(installer): Composer 설치 로그 인코딩으로 인한 진행 상태 응답 빈 본문 보완

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 제보).
This commit is contained in:
HeuJung
2026-07-06 17:09:41 +09:00
parent 5ee1d1f2b5
commit 342486a20b
8 changed files with 615 additions and 246 deletions
+1
View File
@@ -20,6 +20,7 @@
### Fixed
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
## [7.0.1] - 2026-07-03
+22 -1
View File
@@ -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;
}
/**
+77 -50
View File
@@ -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;
}
+21 -9
View File
@@ -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'];
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,251 @@
<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* 설치 로그 기록 시 UTF-8 정규화(scrub) 회귀 테스트.
*
* 배경 (gnuboard/g7#62, dev-g7#445):
* Vendor 설치 방식 "Composer 실행" + 폴링 모드 조합에서 브라우저 콘솔에
* "Unexpected end of JSON input" 이 폭주하고 진행 상황이 표시되지 않는 회귀.
*
* 근본 원인: 폴링 워커가 composer install stdout 을 fgets() 로 읽어 정규화 없이
* addLog() 로 넘긴다. Windows 코드페이지(CP949) 출력이나 다운로드 진행바(\r 갱신)가
* 임의 바이트 경계에서 잘리면 invalid UTF-8 바이트가 생기는데,
* _addLogInternal() 은 mb_detect_encoding(strict=true) 감지 실패 시 raw 바이트를
* 그대로 로그에 저장한다. 그 로그를 포함한 폴링 응답 state-management.php 의
* json_encode() 가 invalid UTF-8 때문에 false 를 반환 → echo false = 빈 본문(HTTP 200)
* → 프론트 res.json() 이 "Unexpected end of JSON input" 실패.
*
* 본 테스트는 근본 지점을 검증한다: addLog() 에 invalid UTF-8 바이트를 주입해도
* getInstallationLogs() 로 읽은 로그 메시지가 항상 유효 UTF-8 이어서
* json_encode() 가 false 가 아니어야 한다.
*
* BASE_PATH 는 PHP 상수로 클래스 라이프사이클 단위 단 한 번 정의 (setUpBeforeClass).
* InstallerStateSchemaTest 와 동일한 temp 격리 패턴을 사용한다.
*/
#[RunClassInSeparateProcess]
#[PreserveGlobalState(false)]
class AddLogUtf8ScrubTest extends TestCase
{
private static string $sharedBase = '';
private static string $skipReason = '';
public static function setUpBeforeClass(): void
{
parent::setUpBeforeClass();
// 안전 가드: BASE_PATH 가 시스템 temp 하위가 아니면 (= 다른 테스트가 프로젝트 루트로 박은 경우)
// setUp 의 installation.log @unlink 가 실제 설치 로그를 파괴할 수 있으므로 skip.
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
if (defined('BASE_PATH')) {
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
if (strpos($resolved, $tempPrefix) !== 0) {
self::$skipReason = 'BASE_PATH ('.$resolved.') 가 시스템 temp 하위가 아님 — '.
'다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 격리 실행 필요: '.
'php vendor/bin/phpunit --filter=AddLogUtf8ScrubTest';
return;
}
self::$sharedBase = (string) BASE_PATH;
} else {
self::$sharedBase = sys_get_temp_dir().'/g7-installer-addlog-test-'.bin2hex(random_bytes(4));
define('BASE_PATH', self::$sharedBase);
}
$logDir = self::$sharedBase.'/storage/logs';
if (! is_dir($logDir)) {
mkdir($logDir, 0755, true);
}
// config.php 의 INSTALLER_BASE_URL 계산이 $_SERVER['SCRIPT_NAME'] 에 의존
if (! isset($_SERVER['SCRIPT_NAME'])) {
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
}
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
require_once dirname(__DIR__, 3).'/public/install/includes/installer-state.php';
require_once dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php';
}
public static function tearDownAfterClass(): void
{
if (self::$skipReason === '' && self::$sharedBase !== '') {
$logFile = self::$sharedBase.'/storage/logs/installation.log';
if (file_exists($logFile)) {
@unlink($logFile);
}
}
parent::tearDownAfterClass();
}
protected function setUp(): void
{
parent::setUp();
if (self::$skipReason !== '') {
$this->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<string, array{string}>
*/
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 라인 송출을 차단해야 한다.'
);
}
}
+40 -35
View File
@@ -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', '<?php');
file_put_contents($this->tempDir . '/autoload.php', '<?php');
file_put_contents($this->tempDir.'/.gitkeep', '');
mkdir($this->tempDir.'/laravel/framework/src', 0755, true);
file_put_contents($this->tempDir.'/laravel/framework/src/Application.php', '<?php');
file_put_contents($this->tempDir.'/autoload.php', '<?php');
// When
$result = deleteDirectory($this->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 {
@@ -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 키가 없습니다');