Files
Gnuboard7/tests/Unit/Installer/AddLogUtf8ScrubTest.php
HeuJung 01d319e74b fix(core,installer): 프로세스 출력 인코딩으로 인한 JSON 빈 응답 차단
한국어 Windows 에서 whoami 가 CP949 로 출력해 계정명에 한글이 있으면
invalid UTF-8 이 응답 배열에 실리고 json_encode 가 false 를 반환한다.
echo false 는 빈 문자열이라 HTTP 200 + 빈 본문이 나가는데 예외도 로그도
남지 않아, 설치 마법사 2단계가 진행 불가 상태로 멈춘다.

2026-07 수정(7.0.2)은 로그 축 4곳만 막았다. 출처에서 정규화하고(1차),
응답 경계를 단일 헬퍼로 닫고(2차), 실패 시 문제 필드 경로를 로그에
남기도록(3차) 세 층으로 처리해 다른 경로가 열린 채 남지 않게 했다.

정규화는 복원 가능한 코드페이지 출력을 먼저 되살리므로, 종전 mb_scrub
단독 처리와 달리 한글이 U+FFFD 로 훼손되지 않는다.

같은 원인으로 한국어 Windows 에서 관리자 환경설정의 시스템 정보가
500 이 되던 것도 함께 막았다.
2026-08-29 15:13:14 +09:00

290 lines
12 KiB
PHP

<?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, '인코딩 결과 본문이 비어 있으면 안 된다.');
}
/**
* 한국어 Windows 의 CP949 출력은 U+FFFD 로 훼손되지 않고 복원되어야 한다.
*
* 이전 구현은 mb_scrub 단독이라 한글이 물음표/대체문자로 뭉개졌다.
* 로그는 운영자가 원인을 찾는 유일한 단서이므로 읽을 수 있어야 한다 (gnuboard/g7#62).
*/
#[Test]
public function addlog_restores_cp949_korean_output(): void
{
addLog('warmup');
$expected = 'it-manager\\티아모라';
$raw = (string) mb_convert_encoding($expected, 'CP949', 'UTF-8');
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'), '표본은 invalid UTF-8 이어야 한다.');
addLog($raw);
$logs = getInstallationLogs();
$message = $logs[count($logs) - 1]['message'] ?? '';
$this->assertSame(
$expected,
$message,
'CP949 한글 출력은 U+FFFD 훼손 없이 원문으로 복원되어야 한다.'
);
}
#[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 이 빈 data 라인을 송출할 수 없는지
* 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식).
*
* 가드는 인라인 플래그에서 공용 헬퍼 installer_json_encode 로 옮겨졌다.
* 헬퍼는 값을 정규화하고 substitute 를 적용하며, 실패해도 파싱 가능한 JSON 을 돌려주므로
* 빈 data 라인이 나갈 수 있는 경로가 없다. 계약은 그대로이고 수단만 단일화됐다.
*/
#[Test]
public function sse_emitter_source_routes_through_guarded_encoder(): 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(
'installer_json_encode(',
$sseBody,
'SseEmitter::emit 은 가드된 공용 인코더를 경유해야 한다 '.
'(invalid UTF-8 로그로 인한 빈 SSE data 라인 송출 차단).'
);
// raw json_encode 로 되돌아가면 빈 data 라인 경로가 되살아난다.
$this->assertDoesNotMatchRegularExpression(
'/(?<![A-Za-z0-9_])json_encode\\s*\\(/',
$sseBody,
'SseEmitter::emit 에 가드 없는 json_encode 가 남아 있으면 안 된다.'
);
}
}