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 이 되던 것도 함께 막았다.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Services;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 시스템 정보 응답의 직렬화 계약 테스트.
|
||||
*
|
||||
* 배경 (gnuboard/g7#62 와 동형):
|
||||
* `getSystemInfo()` 는 외부 프로세스 출력(powershell/wmic CPU 조회)과 환경값을
|
||||
* 한 배열에 모아 관리자 환경설정 화면으로 내보낸다. 그 안에 invalid UTF-8 이
|
||||
* 섞이면 Laravel JsonResponse 직렬화가 Malformed UTF-8 로 실패해 화면이 500 이 된다.
|
||||
* `safeSystemProbe` 는 probe 예외만 잡고 직렬화 예외는 잡지 못한다.
|
||||
*
|
||||
* 단위 테스트(SettingsServiceCpuInfoTest)는 CPU 필드 하나의 정규화 계약만 본다.
|
||||
* 이 테스트는 **페이로드 전체**가 직렬화 가능한지를 잠가, 나중에 다른 probe 가
|
||||
* 외부 출력을 그대로 싣더라도 같은 결함이 재발하지 않게 한다.
|
||||
*
|
||||
* DB 스키마를 건드리지 않으므로 RefreshDatabase 를 쓰지 않는다.
|
||||
*/
|
||||
class SettingsServiceSystemInfoTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function system_info_payload_is_always_json_serializable(): void
|
||||
{
|
||||
$info = app(SettingsService::class)->getSystemInfo();
|
||||
|
||||
$this->assertNotSame([], $info, '시스템 정보 페이로드가 비어 있으면 안 된다.');
|
||||
|
||||
$encoded = json_encode($info, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$this->assertNotFalse(
|
||||
$encoded,
|
||||
'getSystemInfo() 페이로드 직렬화 실패: '.json_last_error_msg()
|
||||
.' — invalid UTF-8 필드: '.implode(', ', $this->invalidUtf8Paths($info))
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function every_string_in_system_info_is_valid_utf8(): void
|
||||
{
|
||||
$info = app(SettingsService::class)->getSystemInfo();
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$this->invalidUtf8Paths($info),
|
||||
'시스템 정보에 유효하지 않은 UTF-8 문자열이 있으면 관리자 화면이 500 이 된다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* invalid UTF-8 문자열이 있는 키 경로를 점 표기로 수집합니다.
|
||||
*
|
||||
* @param mixed $value 검사할 값
|
||||
* @param string $prefix 상위 경로
|
||||
* @return array<int, string> 점 구분 경로 목록
|
||||
*/
|
||||
private function invalidUtf8Paths(mixed $value, string $prefix = ''): array
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return mb_check_encoding($value, 'UTF-8') ? [] : [$prefix === '' ? '(root)' : $prefix];
|
||||
}
|
||||
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$paths = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
$label = $prefix === '' ? (string) $key : $prefix.'.'.$key;
|
||||
$paths = array_merge($paths, $this->invalidUtf8Paths($item, $label));
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,33 @@ class AddLogUtf8ScrubTest extends TestCase
|
||||
$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
|
||||
{
|
||||
@@ -225,11 +252,15 @@ class AddLogUtf8ScrubTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* progress-emitter.php 의 SseEmitter::emit 이 JSON_INVALID_UTF8_SUBSTITUTE 를 적용하는지
|
||||
* progress-emitter.php 의 SseEmitter::emit 이 빈 data 라인을 송출할 수 없는지
|
||||
* 소스 레벨로 회귀 검증한다 (PollingResponseFlushTest 정적 검증과 동일 방식).
|
||||
*
|
||||
* 가드는 인라인 플래그에서 공용 헬퍼 installer_json_encode 로 옮겨졌다.
|
||||
* 헬퍼는 값을 정규화하고 substitute 를 적용하며, 실패해도 파싱 가능한 JSON 을 돌려주므로
|
||||
* 빈 data 라인이 나갈 수 있는 경로가 없다. 계약은 그대로이고 수단만 단일화됐다.
|
||||
*/
|
||||
#[Test]
|
||||
public function sse_emitter_source_uses_invalid_utf8_substitute_flag(): void
|
||||
public function sse_emitter_source_routes_through_guarded_encoder(): void
|
||||
{
|
||||
$source = (string) file_get_contents(
|
||||
dirname(__DIR__, 3).'/public/install/includes/progress-emitter.php'
|
||||
@@ -242,10 +273,17 @@ class AddLogUtf8ScrubTest extends TestCase
|
||||
$sseBody = substr($source, $classStart, $classEnd !== false ? $classEnd - $classStart : null);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'JSON_INVALID_UTF8_SUBSTITUTE',
|
||||
'installer_json_encode(',
|
||||
$sseBody,
|
||||
'SseEmitter::emit 의 json_encode 는 JSON_INVALID_UTF8_SUBSTITUTE 를 적용해 '.
|
||||
'invalid UTF-8 로그로 인한 빈 SSE data 라인 송출을 차단해야 한다.'
|
||||
'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 가 남아 있으면 안 된다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 인스톨러 JSON 응답 경계 테스트.
|
||||
*
|
||||
* 배경 (gnuboard/g7#62, sir.kr 566085):
|
||||
* `echo json_encode($data)` 는 $data 에 invalid UTF-8 이 섞이면 false 를 반환하고,
|
||||
* `echo false` 는 빈 문자열이라 HTTP 200 + 빈 본문이 나간다. 프론트는
|
||||
* "Unexpected end of JSON input" 으로 죽는데 서버에는 예외도 로그도 없다.
|
||||
*
|
||||
* 2026-07 수정(#445)은 로그 축 4곳만 막았다. 응답 경계 전체를 단일 헬퍼로 닫지 않으면
|
||||
* 다음 사람이 `echo json_encode` 를 다시 써서 같은 결함이 재발한다.
|
||||
*
|
||||
* BASE_PATH 격리 패턴은 AddLogUtf8ScrubTest 와 동일하다.
|
||||
*/
|
||||
#[RunClassInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
class InstallerJsonOutputTest extends TestCase
|
||||
{
|
||||
private static string $sharedBase = '';
|
||||
|
||||
private static string $skipReason = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
$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 하위가 아님 — '.
|
||||
'격리 실행 필요: php vendor/bin/phpunit --filter=InstallerJsonOutputTest';
|
||||
|
||||
return;
|
||||
}
|
||||
self::$sharedBase = (string) BASE_PATH;
|
||||
} else {
|
||||
self::$sharedBase = sys_get_temp_dir().'/g7-installer-json-test-'.bin2hex(random_bytes(4));
|
||||
define('BASE_PATH', self::$sharedBase);
|
||||
}
|
||||
|
||||
$logDir = self::$sharedBase.'/storage/logs';
|
||||
if (! is_dir($logDir)) {
|
||||
mkdir($logDir, 0755, true);
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private static function cp949(string $utf8): string
|
||||
{
|
||||
return (string) mb_convert_encoding($utf8, 'CP949', 'UTF-8');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function encode_restores_cp949_korean_account_name(): void
|
||||
{
|
||||
$raw = self::cp949('it-manager\\티아모라');
|
||||
|
||||
// 사전 조건: 표본이 실제로 결함을 재현해야 한다.
|
||||
$this->assertFalse(json_encode(['u' => $raw]), '표본은 표준 json_encode 를 false 로 만들어야 한다.');
|
||||
|
||||
$json = installer_json_encode(['u' => $raw]);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertNotSame('', $json, '응답 본문은 절대 비어 있으면 안 된다.');
|
||||
|
||||
$decoded = json_decode($json, true);
|
||||
$this->assertIsArray($decoded, '반환값은 항상 파싱 가능한 JSON 이어야 한다.');
|
||||
$this->assertSame('it-manager\\티아모라', $decoded['u'], '한글 계정명이 원문 그대로 복원되어야 한다.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function encode_never_returns_empty_for_damaged_bytes(): void
|
||||
{
|
||||
foreach (["a\xFFb", "emoji \xF0\x9F head", "tail\x80end"] as $raw) {
|
||||
$json = installer_json_encode(['v' => $raw]);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertNotSame('', $json, '손상 바이트에서도 본문이 비면 안 된다.');
|
||||
$this->assertIsArray(json_decode($json, true), '반환값은 파싱 가능해야 한다.');
|
||||
}
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function encode_failure_falls_back_to_json_and_logs_key_path(): void
|
||||
{
|
||||
// INF 는 UTF-8 과 무관하게 json_encode 를 실패시킨다 (폴백 경로 강제 진입).
|
||||
$json = installer_json_encode(['bad' => INF]);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertNotSame('', $json, '인코딩이 실패해도 빈 본문을 내보내면 안 된다.');
|
||||
|
||||
$decoded = json_decode($json, true);
|
||||
$this->assertIsArray($decoded, '폴백 응답 자체는 반드시 파싱 가능해야 한다.');
|
||||
$this->assertFalse($decoded['success'], '폴백 응답은 실패를 명시해야 한다.');
|
||||
$this->assertSame('json_encode_failed', $decoded['error']);
|
||||
$this->assertArrayHasKey('invalid_paths', $decoded);
|
||||
|
||||
// 관측성: 제보 사례에서 로그에 흔적이 0 이던 것이 이 결함의 핵심 어려움이었다.
|
||||
$logFile = self::$sharedBase.'/storage/logs/installation.log';
|
||||
$this->assertFileExists($logFile, '인코딩 실패는 반드시 설치 로그에 남아야 한다.');
|
||||
$this->assertStringContainsString(
|
||||
'[installer-json] encode failed',
|
||||
(string) file_get_contents($logFile),
|
||||
'로그에 실패 위치를 특정할 수 있는 행이 있어야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function encode_failure_preserves_caller_supplied_fallback_shape(): void
|
||||
{
|
||||
// 폴링 응답(state-management::getState)은 프론트 PollingMonitor 가 `state.status`
|
||||
// 로 진행/완료/실패를 판정한다. 그 키가 빠진 폴백은 파싱은 되지만 아무 전이도
|
||||
// 일으키지 못해 화면이 조용히 멈춘 것처럼 보이고, JSON 이 유효하므로 파싱 실패
|
||||
// 카운터(5회)에도 걸리지 않는다. 폴백에서도 소비자 계약이 유지되어야 한다.
|
||||
$json = installer_json_encode(
|
||||
['logs' => ['line'], 'broken' => INF],
|
||||
JSON_UNESCAPED_UNICODE,
|
||||
['status' => 'running', 'logs' => [], 'log_total' => 7]
|
||||
);
|
||||
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
$this->assertIsArray($decoded, '폴백 응답은 파싱 가능해야 한다.');
|
||||
$this->assertSame('running', $decoded['status'], '호출자가 넘긴 status 가 폴백에 살아 있어야 한다.');
|
||||
$this->assertSame([], $decoded['logs']);
|
||||
$this->assertSame(7, $decoded['log_total']);
|
||||
|
||||
// 오류 정보는 호출자 형태 위에 얹혀 함께 나간다.
|
||||
$this->assertFalse($decoded['success']);
|
||||
$this->assertSame('json_encode_failed', $decoded['error']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function encode_keeps_unescaped_unicode_by_default(): void
|
||||
{
|
||||
$json = installer_json_encode(['msg' => '설치']);
|
||||
|
||||
$this->assertStringContainsString('설치', $json, '기존 JSON_UNESCAPED_UNICODE 계약을 유지해야 한다.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function js_escape_still_produces_valid_javascript_literal(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
|
||||
|
||||
// JSON_HEX_QUOT 로 인용부호를 이스케이프하는 기존 계약(수정 전과 동일)
|
||||
$this->assertSame('"a\\u0022b"', js_escape('a"b'), 'js_escape 의 기존 이스케이프 계약이 유지되어야 한다.');
|
||||
|
||||
// 한글 계정명이 섞여도 '""' 폴백으로 떨어지지 않아야 한다.
|
||||
$escaped = js_escape(self::cp949('티아모라'));
|
||||
$this->assertNotSame('""', $escaped, 'CP949 입력에서 빈 문자열 폴백으로 떨어지면 안 된다.');
|
||||
$this->assertSame('티아모라', json_decode($escaped, true));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function get_web_server_user_always_returns_valid_utf8(): void
|
||||
{
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
|
||||
|
||||
$user = getWebServerUser();
|
||||
$group = getWebServerGroup();
|
||||
|
||||
// 머신 무관 계약: 출처가 무엇이든 응답에 실릴 값은 항상 유효 UTF-8 이어야 한다.
|
||||
$this->assertTrue(
|
||||
$user === null || mb_check_encoding($user, 'UTF-8'),
|
||||
'getWebServerUser() 반환값은 항상 유효 UTF-8 이어야 한다.'
|
||||
);
|
||||
$this->assertTrue(
|
||||
$group === null || mb_check_encoding($group, 'UTF-8'),
|
||||
'getWebServerGroup() 반환값은 항상 유효 UTF-8 이어야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적 계약: 인스톨러의 출력 경로에 raw `json_encode(` 가 남아 있으면 안 된다.
|
||||
*
|
||||
* 이 테스트가 없으면 다음 사람이 `echo json_encode` 를 다시 쓰고 같은 결함이 재발한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function no_raw_json_encode_remains_in_installer_output_paths(): void
|
||||
{
|
||||
$root = dirname(__DIR__, 3).'/public/install';
|
||||
|
||||
/**
|
||||
* 면제 대상 (각각 사유 있음).
|
||||
*
|
||||
* - utf8.php : installer_json_encode 구현 본체 (여기가 유일한 json_encode 자리)
|
||||
* - functions.php:574 : md5 캐시 키 생성 — 출력이 아니다
|
||||
* - _guard.php : 다른 파일을 include 하지 않는 독립 진입 가드, ASCII 리터럴만 출력
|
||||
* - session.php : config.php 보다 먼저 로드되는 경로가 있어 헬퍼를 못 쓴다, ko/en 리터럴만
|
||||
* - installer-state.php: addLog 재귀 금지 — 상태 저장은 자체 가드(§saveInstallationState)
|
||||
*/
|
||||
$allowlist = [
|
||||
'includes/utf8.php',
|
||||
'api/_guard.php',
|
||||
'includes/session.php',
|
||||
'includes/installer-state.php',
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
|
||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($it as $file) {
|
||||
if (! $file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rel = str_replace('\\', '/', substr($file->getPathname(), strlen($root) + 1));
|
||||
if (in_array($rel, $allowlist, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) file_get_contents($file->getPathname());
|
||||
$lines = explode("\n", $source);
|
||||
|
||||
foreach ($lines as $i => $line) {
|
||||
// installer_json_encode 는 허용, 그 외 json_encode( 호출은 위반
|
||||
if (! preg_match('/(?<![A-Za-z0-9_])json_encode\s*\(/', $line)) {
|
||||
continue;
|
||||
}
|
||||
if (str_contains($line, 'installer_json_encode')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 출력이 아닌 용도(md5 캐시 키 등)는 같은 줄 또는 바로 위 주석의
|
||||
// 'json-encode:allow' 표식으로 면제한다 (사유를 코드에 남기게 하기 위함).
|
||||
$context = implode("\n", array_slice($lines, max(0, $i - 3), 4));
|
||||
if (str_contains($context, 'json-encode:allow')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = $rel.':'.($i + 1).' — '.trim($line);
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
$violations,
|
||||
"인스톨러 출력 경로에 raw json_encode 가 남아 있다. installer_json_encode 를 사용할 것:\n".
|
||||
implode("\n", $violations)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프론트에 노출되는 신규 안내 문구가 ko/en 양쪽에 정의되어 있어야 한다.
|
||||
*
|
||||
* 누락 시 화면에 `error_empty_server_response` 같은 키가 그대로 노출된다.
|
||||
*/
|
||||
#[Test]
|
||||
public function new_error_message_keys_exist_in_both_locales(): void
|
||||
{
|
||||
$keys = [
|
||||
'error_empty_server_response',
|
||||
'error_invalid_server_response',
|
||||
'error_polling_response_invalid',
|
||||
];
|
||||
|
||||
foreach (['ko', 'en'] as $locale) {
|
||||
$path = dirname(__DIR__, 3)."/public/install/lang/{$locale}.php";
|
||||
$this->assertFileExists($path);
|
||||
|
||||
/** @var array<string, string> $messages */
|
||||
$messages = require $path;
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$this->assertArrayHasKey($key, $messages, "{$locale}.php 에 '{$key}' 가 정의되어야 한다.");
|
||||
$this->assertNotSame('', trim((string) $messages[$key]), "{$locale}.{$key} 는 비어 있으면 안 된다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Installer;
|
||||
|
||||
use PHPUnit\Framework\Attributes\PreserveGlobalState;
|
||||
use PHPUnit\Framework\Attributes\RunClassInSeparateProcess;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 설치 2단계(설치 환경 확인) 응답 인코딩 회귀 테스트.
|
||||
*
|
||||
* 배경 (sir.kr 566085 / gnuboard/g7#62 reopen):
|
||||
* `whoami` = `it-manager\티아모라` (한글 계정명) 인 Windows 에서
|
||||
* `check-configuration.php?action=requirements` 가 HTTP 200 + 빈 본문을 반환하고
|
||||
* 화면에 "요구사항 검증 실패: ... Unexpected end of JSON input" 만 표시된다.
|
||||
*
|
||||
* 원인: whoami 의 CP949 출력이 `directories.web_server_user` /
|
||||
* `required_files.*.owner` 에 정규화 없이 실려 json_encode() 가 false 를 반환한다.
|
||||
*
|
||||
* getWebServerUser() 는 전역 함수라 mock 할 수 없으므로,
|
||||
* checkRequirements() 가 만드는 것과 같은 배열 형태에 CP949 값을 주입하고
|
||||
* **같은 직렬화 경로**(installer_json_encode)로 인코딩해 계약을 검사한다.
|
||||
*/
|
||||
#[RunClassInSeparateProcess]
|
||||
#[PreserveGlobalState(false)]
|
||||
class RequirementsResponseUtf8Test extends TestCase
|
||||
{
|
||||
private static string $sharedBase = '';
|
||||
|
||||
private static string $skipReason = '';
|
||||
|
||||
public static function setUpBeforeClass(): void
|
||||
{
|
||||
parent::setUpBeforeClass();
|
||||
|
||||
$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 하위가 아님 — '.
|
||||
'격리 실행 필요: php vendor/bin/phpunit --filter=RequirementsResponseUtf8Test';
|
||||
|
||||
return;
|
||||
}
|
||||
self::$sharedBase = (string) BASE_PATH;
|
||||
} else {
|
||||
self::$sharedBase = sys_get_temp_dir().'/g7-installer-req-utf8-'.bin2hex(random_bytes(4));
|
||||
define('BASE_PATH', self::$sharedBase);
|
||||
}
|
||||
|
||||
foreach (['/storage/logs', '/storage/app', '/bootstrap/cache'] as $dir) {
|
||||
if (! is_dir(self::$sharedBase.$dir)) {
|
||||
mkdir(self::$sharedBase.$dir, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($_SERVER['SCRIPT_NAME'])) {
|
||||
$_SERVER['SCRIPT_NAME'] = '/install/index.php';
|
||||
}
|
||||
|
||||
// 라이브러리 모드 — 파일 로드만으로 요청을 처리하지 않도록 한다.
|
||||
if (! defined('CHECK_CONFIGURATION_LIBRARY')) {
|
||||
define('CHECK_CONFIGURATION_LIBRARY', true);
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/config.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/includes/functions.php';
|
||||
require_once dirname(__DIR__, 3).'/public/install/api/check-configuration.php';
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
if (self::$skipReason !== '') {
|
||||
$this->markTestSkipped(self::$skipReason);
|
||||
}
|
||||
}
|
||||
|
||||
private static function cp949(string $utf8): string
|
||||
{
|
||||
return (string) mb_convert_encoding($utf8, 'CP949', 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 2단계 응답이 한글 계정명 환경에서도 본문을 실어 보내고 계정명을 복원해야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function requirements_response_survives_korean_account_name(): void
|
||||
{
|
||||
$account = 'it-manager\\티아모라';
|
||||
$raw = self::cp949($account);
|
||||
|
||||
// checkDirectoryPermissions() / checkRequiredFiles() 가 만드는 형태를 그대로 모사
|
||||
$requirements = [
|
||||
'directories' => [
|
||||
'required' => true,
|
||||
'web_server_group' => $raw,
|
||||
'web_server_user' => $raw,
|
||||
'all_passed' => false,
|
||||
'message' => '디렉토리 권한을 확인하세요.',
|
||||
],
|
||||
'required_files' => [
|
||||
'required' => true,
|
||||
'files' => [
|
||||
'.env' => ['exists' => true, 'writable' => false, 'owner' => $raw],
|
||||
],
|
||||
'all_passed' => false,
|
||||
],
|
||||
'all_required_passed' => false,
|
||||
'is_windows' => true,
|
||||
];
|
||||
|
||||
// 사전 조건: 이 배열이 실제로 결함을 재현해야 한다.
|
||||
$this->assertFalse(
|
||||
json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
|
||||
'표본 배열은 기존 직렬화 방식에서 false 가 되어야 한다 (결함 재현).'
|
||||
);
|
||||
|
||||
// checkRequirements() 가 쓰는 것과 같은 경로
|
||||
$json = installer_json_encode($requirements, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$this->assertIsString($json);
|
||||
$this->assertNotSame('', $json, 'HTTP 200 + 빈 본문이 다시 나가면 안 된다.');
|
||||
|
||||
$decoded = json_decode($json, true);
|
||||
$this->assertIsArray($decoded, '프론트의 response.json() 이 파싱할 수 있어야 한다.');
|
||||
|
||||
$this->assertSame($account, $decoded['directories']['web_server_user']);
|
||||
$this->assertSame($account, $decoded['directories']['web_server_group']);
|
||||
$this->assertSame($account, $decoded['required_files']['files']['.env']['owner']);
|
||||
|
||||
// 나머지 필드가 정규화 과정에서 사라지거나 변형되지 않아야 한다.
|
||||
$this->assertFalse($decoded['all_required_passed']);
|
||||
$this->assertTrue($decoded['is_windows']);
|
||||
$this->assertSame('디렉토리 권한을 확인하세요.', $decoded['directories']['message']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 회귀 가드 (머신 무관): 출처가 posix / whoami / 환경변수 어느 쪽이든
|
||||
* 응답에 실릴 값은 항상 유효 UTF-8 이어야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function web_server_identity_is_always_encodable(): void
|
||||
{
|
||||
$payload = [
|
||||
'web_server_user' => getWebServerUser(),
|
||||
'web_server_group' => getWebServerGroup(),
|
||||
];
|
||||
|
||||
$this->assertNotFalse(
|
||||
json_encode($payload),
|
||||
'현재 머신의 웹서버 사용자/그룹 값이 json_encode 를 무력화하면 안 된다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Support\ProcessOutputEncoding;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 시스템 정보(CPU) 수집의 인코딩 계약 테스트.
|
||||
*
|
||||
* 배경 (gnuboard/g7#62 와 동형):
|
||||
* `getCpuInfo()` 는 `powershell … 2>&1` / `wmic … 2>&1` 의 출력을 문자열로 반환한다.
|
||||
* `2>&1` 로 합쳐진 오류 문장은 시스템 코드페이지(한국어 Windows = CP949)로 나오므로
|
||||
* 정규화 없이 반환하면 그 값이 시스템 정보 API 응답에 실려 JsonResponse 직렬화가
|
||||
* Malformed UTF-8 로 실패한다 — 관리자 환경설정 화면이 500 이 된다.
|
||||
*
|
||||
* `safeSystemProbe` 는 probe 자체의 예외만 잡고 직렬화 예외는 잡지 못하므로
|
||||
* 출처에서 정규화하는 것이 유일한 차단 지점이다.
|
||||
*
|
||||
* `getCpuInfo()` 는 protected 이고 `shell_exec` 을 주입할 수 없으므로,
|
||||
* 이 테스트는 그 메서드가 의존하는 정규화 계약을 검사한다.
|
||||
*/
|
||||
class SettingsServiceCpuInfoTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 한국어 Windows 의 powershell 오류 문장을 재현한 CP949 표본.
|
||||
*/
|
||||
private const ERROR_SENTENCE = "'powershell'은(는) 내부 또는 외부 명령으로 인식되지 않습니다.";
|
||||
|
||||
#[Test]
|
||||
public function cp949_shell_error_sentence_becomes_serializable(): void
|
||||
{
|
||||
$raw = (string) mb_convert_encoding(self::ERROR_SENTENCE, 'CP949', 'UTF-8');
|
||||
|
||||
// 사전 조건: 이 바이트가 실제로 직렬화를 무력화해야 의미가 있다.
|
||||
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'));
|
||||
$this->assertFalse(json_encode(['cpu' => trim($raw)]));
|
||||
|
||||
$normalized = ProcessOutputEncoding::normalize($raw);
|
||||
|
||||
$this->assertSame(self::ERROR_SENTENCE, $normalized, '한글 오류 문장이 원문대로 복원되어야 한다.');
|
||||
$this->assertNotFalse(
|
||||
json_encode(['cpu' => trim($normalized)]),
|
||||
'정규화 후에는 시스템 정보 응답이 직렬화되어야 한다 (500 차단).'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정규화가 기존 판정 로직을 바꾸지 않아야 한다.
|
||||
*
|
||||
* 오류 문장이 CPU 이름으로 보이는 표시 문제는 이번 범위 밖이며,
|
||||
* 영문 'error' 판정은 그대로 유지된다 (기능 축소 없음).
|
||||
*/
|
||||
#[Test]
|
||||
public function normalization_preserves_existing_error_detection(): void
|
||||
{
|
||||
$englishError = "wmic : The term 'wmic' is not recognized. ".
|
||||
'FullyQualifiedErrorId : CommandNotFoundException';
|
||||
|
||||
$normalized = ProcessOutputEncoding::normalize($englishError);
|
||||
|
||||
$this->assertSame($englishError, $normalized, '유효 UTF-8 출력은 변형되지 않아야 한다.');
|
||||
$this->assertStringContainsString(
|
||||
'error',
|
||||
strtolower($normalized),
|
||||
'기존 error 판정이 정규화 후에도 동일하게 동작해야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 정상적인 CPU 이름은 한 글자도 변하지 않아야 한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function normal_cpu_name_is_unchanged(): void
|
||||
{
|
||||
$name = 'AMD Ryzen 9 7950X 16-Core Processor';
|
||||
|
||||
$this->assertSame($name, ProcessOutputEncoding::normalize($name));
|
||||
}
|
||||
|
||||
/**
|
||||
* getCpuInfo() 가 정규화를 실제로 경유하는지 소스 레벨 회귀 검증.
|
||||
*
|
||||
* 정규화를 걷어내면 이 클래스의 나머지 계약이 전부 통과해도 500 이 되살아난다.
|
||||
*/
|
||||
#[Test]
|
||||
public function get_cpu_info_source_routes_shell_output_through_normalizer(): void
|
||||
{
|
||||
$source = (string) file_get_contents(dirname(__DIR__, 3).'/app/Services/SettingsService.php');
|
||||
|
||||
$start = strpos($source, 'protected function getCpuInfo()');
|
||||
$this->assertNotFalse($start, 'getCpuInfo() 를 찾을 수 없습니다.');
|
||||
|
||||
$body = substr($source, $start, 2000);
|
||||
|
||||
// shell_exec 출력을 소비하는 두 분기(powershell / wmic) 모두 정규화를 거쳐야 한다.
|
||||
$this->assertSame(
|
||||
2,
|
||||
substr_count($body, 'ProcessOutputEncoding::normalize('),
|
||||
'powershell·wmic 두 분기 모두 외부 출력을 정규화해야 한다 — '.
|
||||
'한쪽만 막으면 폴백 경로에서 같은 500 이 재발한다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Support;
|
||||
|
||||
use App\Support\ProcessOutputEncoding;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* 외부 프로세스 출력 인코딩 정규화 테스트.
|
||||
*
|
||||
* 배경 (gnuboard/g7#62, sir.kr 566085):
|
||||
* 한국어 Windows 에서 `whoami` 는 OEM 코드페이지(CP949) 로 출력한다.
|
||||
* 계정명에 한글이 포함되면 그 바이트가 invalid UTF-8 이라
|
||||
* json_encode() 가 false 를 반환하고 `echo false` = 빈 본문(HTTP 200)이 나간다.
|
||||
* 예외도 로그도 남지 않아 운영자가 원인을 특정할 수 없다.
|
||||
*
|
||||
* 이 클래스는 그 출력을 "항상 유효한 UTF-8" 로 만드는 단일 출처다.
|
||||
* 인스톨러(프레임워크 없음)와 코어가 함께 쓴다.
|
||||
*/
|
||||
class ProcessOutputEncodingTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 한국어 Windows 의 whoami 출력을 재현한 CP949 바이트.
|
||||
*
|
||||
* hex 를 손으로 적지 않는다 — 오타로 다른 글자가 되는 사고가 실제로 있었다.
|
||||
*/
|
||||
private static function cp949(string $utf8): string
|
||||
{
|
||||
return (string) mb_convert_encoding($utf8, 'CP949', 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 유효 UTF-8 입력은 한 바이트도 변형되지 않아야 한다.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function validUtf8Provider(): array
|
||||
{
|
||||
return [
|
||||
'empty' => [''],
|
||||
'ascii' => ['www-data'],
|
||||
'korean' => ['it-manager\\티아모라'],
|
||||
'emoji' => ['user 🎉 name'],
|
||||
'mixed' => ['서버-01 / nginx / 그룹'],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('validUtf8Provider')]
|
||||
public function valid_utf8_passes_through_unchanged(string $value): void
|
||||
{
|
||||
$this->assertSame(
|
||||
$value,
|
||||
ProcessOutputEncoding::normalize($value),
|
||||
'유효 UTF-8 입력은 정규화 대상이 아니므로 변형되면 안 된다.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cp949_korean_account_name_is_restored_exactly(): void
|
||||
{
|
||||
$expected = 'it-manager\\티아모라';
|
||||
$raw = self::cp949($expected);
|
||||
|
||||
// 사전 조건: 표본이 실제로 결함을 재현하는 바이트여야 의미가 있다.
|
||||
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'), '표본은 invalid UTF-8 이어야 한다.');
|
||||
$this->assertFalse(json_encode(['u' => $raw]), '표본은 json_encode 를 false 로 만들어야 한다.');
|
||||
|
||||
$this->assertSame(
|
||||
$expected,
|
||||
ProcessOutputEncoding::normalize($raw),
|
||||
'CP949 한글 계정명은 원문 그대로 복원되어야 한다 (U+FFFD 훼손 금지).'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function cp949_extended_hangul_is_restored(): void
|
||||
{
|
||||
// KS X 1001 밖의 확장 한글 — mb_detect_encoding 이 EUC-KR 이 아닌 CP949 로 답하는 영역
|
||||
$expected = '똠방각하';
|
||||
$raw = self::cp949($expected);
|
||||
|
||||
$this->assertFalse(mb_check_encoding($raw, 'UTF-8'), '표본은 invalid UTF-8 이어야 한다.');
|
||||
$this->assertSame($expected, ProcessOutputEncoding::normalize($raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* 복원 불가능한 손상 바이트 표본.
|
||||
*
|
||||
* AddLogUtf8ScrubTest::invalidUtf8Provider 와 같은 표본 — composer 진행바(\r 갱신)가
|
||||
* 임의 바이트 경계에서 잘릴 때 생기는 형태다. 여기서는 "정확한 복원" 이 아니라
|
||||
* "응답이 살아남는다" 만 요구한다.
|
||||
*
|
||||
* @return array<string, array{string}>
|
||||
*/
|
||||
public static function unrecoverableProvider(): 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"],
|
||||
'lone continuation byte' => ["tail\x80end"],
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[DataProvider('unrecoverableProvider')]
|
||||
public function damaged_bytes_still_yield_encodable_utf8(string $raw): void
|
||||
{
|
||||
$normalized = ProcessOutputEncoding::normalize($raw);
|
||||
|
||||
$this->assertTrue(
|
||||
mb_check_encoding($normalized, 'UTF-8'),
|
||||
'복원 불가능한 바이트라도 결과는 반드시 유효 UTF-8 이어야 한다.'
|
||||
);
|
||||
$this->assertNotFalse(
|
||||
json_encode(['v' => $normalized]),
|
||||
'정규화 결과는 json_encode 를 false 로 만들면 안 된다 (빈 본문 방지).'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function normalize_deep_covers_nested_values_and_keys(): void
|
||||
{
|
||||
$cp949 = self::cp949('티아모라');
|
||||
|
||||
$input = [
|
||||
'directories' => [
|
||||
'web_server_user' => $cp949,
|
||||
'nested' => [['owner' => $cp949], 42, true, null, 1.5],
|
||||
],
|
||||
$cp949 => 'key-is-invalid-too',
|
||||
];
|
||||
|
||||
$out = ProcessOutputEncoding::normalizeDeep($input);
|
||||
|
||||
$this->assertNotFalse(
|
||||
json_encode($out),
|
||||
'배열 트리 전체가 정규화되어야 json_encode 가 살아남는다.'
|
||||
);
|
||||
$this->assertSame('티아모라', $out['directories']['web_server_user']);
|
||||
$this->assertSame('티아모라', $out['directories']['nested'][0]['owner']);
|
||||
$this->assertArrayHasKey('티아모라', $out, '배열 키도 정규화 대상이다.');
|
||||
|
||||
// 문자열 외 스칼라는 타입·값이 보존되어야 한다.
|
||||
$this->assertSame(42, $out['directories']['nested'][1]);
|
||||
$this->assertTrue($out['directories']['nested'][2]);
|
||||
$this->assertNull($out['directories']['nested'][3]);
|
||||
$this->assertSame(1.5, $out['directories']['nested'][4]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalid_paths_reports_dotted_key_path(): void
|
||||
{
|
||||
$cp949 = self::cp949('티아모라');
|
||||
|
||||
$this->assertSame(
|
||||
['directories.web_server_user'],
|
||||
ProcessOutputEncoding::invalidPaths(['directories' => ['web_server_user' => $cp949]]),
|
||||
'encode 실패 시 운영자가 어느 필드가 원인인지 알 수 있어야 한다.'
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
[],
|
||||
ProcessOutputEncoding::invalidPaths(['directories' => ['web_server_user' => 'www-data']]),
|
||||
'정상 입력에서는 보고할 경로가 없어야 한다.'
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function invalid_paths_reports_scalar_root_and_invalid_keys(): void
|
||||
{
|
||||
$cp949 = self::cp949('티아모라');
|
||||
|
||||
$this->assertSame([''], ProcessOutputEncoding::invalidPaths($cp949), '스칼라 루트도 보고 대상이다.');
|
||||
|
||||
$paths = ProcessOutputEncoding::invalidPaths([$cp949 => 'ok']);
|
||||
$this->assertNotEmpty($paths, 'invalid 한 배열 키도 encode 를 실패시키므로 보고되어야 한다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows OEM 코드페이지 폴백(2단계) 경로.
|
||||
*
|
||||
* 결과 문자열은 머신 로케일에 따라 달라지므로 값은 단언하지 않는다 —
|
||||
* "항상 유효 UTF-8 을 돌려준다" 는 계약만 검사한다.
|
||||
*/
|
||||
#[Test]
|
||||
public function windows_oem_fallback_still_returns_valid_utf8(): void
|
||||
{
|
||||
if (PHP_OS_FAMILY !== 'Windows') {
|
||||
$this->markTestSkipped('Windows OEM 폴백 경로는 Windows 에서만 실행된다.');
|
||||
}
|
||||
|
||||
foreach (["\x82\x60\x82\x61", "\x81\x40", "\xA1\xA1"] as $raw) {
|
||||
$normalized = ProcessOutputEncoding::normalize($raw);
|
||||
$this->assertTrue(
|
||||
mb_check_encoding($normalized, 'UTF-8'),
|
||||
'OEM 폴백을 타더라도 결과는 유효 UTF-8 이어야 한다.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# audit:allow test-scenario-coverage reason: 인스톨러(설치 전 표면)는 설치 완료 사이트에 붙는 Playwright 인프라와 양립하지 않는다 (_guard 가 410). 브라우저 계층은 Chrome MCP 정밀 점검 매트릭스로 대체하며, 그 결과는 작업 이력 문서에 기재한다.
|
||||
|
||||
feature: 인스톨러 프로세스 출력 인코딩 정규화와 JSON 응답 경계
|
||||
|
||||
description: |
|
||||
외부 프로세스 출력(`whoami`, composer stdout, powershell/wmic)은 UTF-8 이 아니라
|
||||
그 명령을 실행한 콘솔의 코드페이지로 나온다. 한국어 Windows(OEM 949)에서 계정명에
|
||||
한글이 있으면 invalid UTF-8 바이트가 응답 배열에 실리고, `json_encode()` 가 false 를
|
||||
반환해 `echo false` = HTTP 200 + 빈 본문이 나간다. 예외도 로그도 남지 않는다.
|
||||
|
||||
핵심 동작:
|
||||
- ProcessOutputEncoding: 한국어 코드페이지 확정 감지 → Windows OEM/ANSI 폴백 → mb_scrub
|
||||
(복원 가능한 출력은 한글 그대로 살리고, 불가능한 바이트만 대체)
|
||||
- installer_json_encode: 정규화 + substitute + 실패 시 파싱 가능한 오류 JSON
|
||||
(false·빈 문자열을 절대 반환하지 않는다)
|
||||
- 출처 정규화: getWebServerUser/Group, composer 스트림, addLog, 상태 파일 저장
|
||||
- 관측성: 정규화가 실제로 필요했으면 설치 로그에 1회 기록, encode 실패는 키 경로까지 기록
|
||||
- 프론트: 빈 본문/비 JSON 응답을 읽을 수 있는 안내 문구로, 폴링은 5회 연속 실패 시 중단
|
||||
|
||||
axes:
|
||||
output_encoding: [utf8, cp949_korean, cp949_extended_hangul, truncated_utf8, single_high_byte, empty]
|
||||
sink:
|
||||
- requirements_response
|
||||
- polling_state_response
|
||||
- sse_event
|
||||
- state_json_file
|
||||
- installation_log
|
||||
- js_embedded_view
|
||||
- core_system_info
|
||||
value_position: [scalar, nested_array_value, array_key]
|
||||
locale: [ko, en]
|
||||
|
||||
exclusions:
|
||||
- { output_encoding: empty, value_position: array_key, reason: "빈 문자열 키는 인스톨러 응답 형태에 존재하지 않는다" }
|
||||
- { sink: core_system_info, value_position: array_key, reason: "CPU 정보는 스칼라 값 한 개" }
|
||||
- { sink: core_system_info, locale: en, reason: "정규화는 로케일에 의존하지 않는다 — ko 표본으로 충분" }
|
||||
- { sink: installation_log, value_position: array_key, reason: "로그 메시지는 스칼라 문자열" }
|
||||
- { sink: installation_log, value_position: nested_array_value, reason: "동일" }
|
||||
|
||||
effects:
|
||||
# 응답이 절대 비지 않는다 (결함 본체)
|
||||
- response_body_never_empty
|
||||
- requirements_response_parses_after_cp949_account_name
|
||||
- polling_response_parses_after_invalid_utf8_log
|
||||
- sse_data_line_never_empty
|
||||
# 복원 품질 (mb_scrub 단독 대비 향상)
|
||||
- korean_account_name_restored_exactly
|
||||
- extended_hangul_restored
|
||||
- valid_utf8_passes_through_unchanged
|
||||
- non_string_scalars_preserve_type_and_value
|
||||
- unrecoverable_bytes_still_yield_encodable_utf8
|
||||
# 출처 차단
|
||||
- web_server_user_always_valid_utf8
|
||||
- composer_stream_line_normalized_before_emit
|
||||
- state_json_file_written_with_substitute_flag
|
||||
- core_cpu_info_normalized_before_serialization
|
||||
# 관측성 (제보 사례에 로그 흔적이 0 이었다)
|
||||
- encode_failure_logged_with_key_path
|
||||
- non_utf8_process_output_logged_once_per_source
|
||||
# 프론트 안내
|
||||
- frontend_shows_readable_message_on_empty_body
|
||||
- frontend_shows_readable_message_on_unparsable_body
|
||||
- polling_stops_after_5_consecutive_parse_failures
|
||||
- polling_parse_failure_counter_resets_on_success
|
||||
- new_error_message_keys_defined_in_ko_and_en
|
||||
# 재발 차단
|
||||
- no_raw_json_encode_remains_in_installer_output_paths
|
||||
- sse_emitter_routes_through_guarded_encoder
|
||||
- get_cpu_info_routes_both_branches_through_normalizer
|
||||
|
||||
test_files:
|
||||
- tests/Unit/Support/ProcessOutputEncodingTest.php
|
||||
- tests/Unit/Installer/InstallerJsonOutputTest.php
|
||||
- tests/Unit/Installer/RequirementsResponseUtf8Test.php
|
||||
- tests/Unit/Installer/AddLogUtf8ScrubTest.php
|
||||
- tests/Unit/Services/SettingsServiceCpuInfoTest.php
|
||||
|
||||
validation:
|
||||
browser_matrix: .claude/docs/chrome-mcp-inspection-matrix.md
|
||||
Reference in New Issue
Block a user