|null 파일이 없거나 형식이 잘못된 경우 null */ function readInstallerRuntime(): ?array { if (! is_file(INSTALLER_RUNTIME_PATH)) { return null; } $data = @include INSTALLER_RUNTIME_PATH; return is_array($data) ? $data : null; } } if (! function_exists('writeInstallerRuntime')) { /** * runtime.php 를 atomic 하게 작성한다. * * 디렉토리 부재 시 생성. 파일 권한은 0600 으로 설정. * * @param array $data 저장할 배열 * @return bool 성공 여부 */ function writeInstallerRuntime(array $data): bool { $dir = dirname(INSTALLER_RUNTIME_PATH); if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { return false; } $php = " $runtime runtime.php 배열 * @return string 최종 .env 콘텐츠 */ function mergeRuntimeIntoEnv(string $envContent, array $runtime): string { // DB 자격증명 치환 — state.config 결손 안전망 $write = $runtime['db']['write'] ?? null; if (is_array($write)) { $envContent = replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', escapeEnvValue((string) ($write['password'] ?? ''))); } $read = $runtime['db']['read'] ?? $write; // read 미지정 시 write 와 동기화 if (is_array($read)) { $envContent = replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? '')); $envContent = replaceEnvLine($envContent, 'DB_READ_PASSWORD', escapeEnvValue((string) ($read['password'] ?? ''))); } if (isset($runtime['db']['prefix'])) { $envContent = replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']); } // APP_KEY 치환 $appKey = $runtime['app']['key'] ?? null; if (is_string($appKey) && str_starts_with($appKey, 'base64:')) { $envContent = replaceEnvLine($envContent, 'APP_KEY', $appKey); } // INSTALLER_COMPLETED 플래그 추가 (CachesModuleStatus 등이 사용) if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) { $envContent = rtrim($envContent) . "\n\n# Installation Status\nINSTALLER_COMPLETED=true\n"; } return $envContent; } } if (! function_exists('escapeEnvValue')) { /** * .env 값 이스케이프 (functions.php 의 동일 함수 polyfill). * * 인스톨러 본 흐름은 functions.php 가 먼저 로드되므로 if 분기로 진입하지 * 않으나, 단위 테스트에서 functions.php 없이 installer-runtime.php 만 * 로드하는 경우를 위한 안전망. * * @param string $value 이스케이프할 값 * @return string 큰따옴표로 감싸고 내부 큰따옴표/백슬래시를 이스케이프한 값 */ function escapeEnvValue(string $value): string { // CR/LF 제거 — .env 라인 주입 차단 (functions.php 의 정의와 동일 정책) if ($value !== '') { $value = str_replace(["\r", "\n"], '', $value); } if ($value === '') { return '""'; } $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value); return '"' . $escaped . '"'; } } if (! function_exists('replaceEnvLine')) { /** * .env 문자열에서 특정 키의 라인을 치환한다. * * 라인이 없으면 끝에 추가. 값에 공백/특수문자가 있는 경우 호출자가 * escapeEnvValue() 등으로 escape 후 전달. * * @param string $envContent .env 본문 * @param string $key 환경 변수 키 * @param string $value 대입할 값 (escape 처리된 상태) * @return string 치환된 .env 본문 */ function replaceEnvLine(string $envContent, string $key, string $value): string { $line = $key . '=' . $value; $pattern = '/^' . preg_quote($key, '/') . '=.*$/m'; $replaced = preg_replace($pattern, $line, $envContent, 1, $count); if ($count === 0) { return rtrim($envContent) . "\n" . $line . "\n"; } return $replaced; } } if (! function_exists('buildInstallerRuntimeFromState')) { /** * 설치 상태(state.json 의 config) 에서 runtime.php 용 배열을 생성한다. * * runtime.php 가 이미 존재하고 APP_KEY 가 있으면 재사용 (재시도 시 키 보존). * 없으면 generateAppKeyInline() 으로 새 키 생성. * * @param array $stateConfig state.json 의 config 섹션 * @return array runtime.php 형식의 배열 */ function buildInstallerRuntimeFromState(array $stateConfig): array { $existing = readInstallerRuntime(); $appKey = $existing['app']['key'] ?? null; if (! $appKey || ! str_starts_with($appKey, 'base64:')) { $appKey = generateAppKeyInline(); } $runtime = [ 'db' => [ 'write' => [ 'host' => $stateConfig['db_write_host'] ?? ($stateConfig['db_host'] ?? '127.0.0.1'), 'port' => $stateConfig['db_write_port'] ?? ($stateConfig['db_port'] ?? '3306'), 'database' => $stateConfig['db_write_database'] ?? ($stateConfig['db_database'] ?? ''), 'username' => $stateConfig['db_write_username'] ?? ($stateConfig['db_username'] ?? ''), 'password' => $stateConfig['db_write_password'] ?? ($stateConfig['db_password'] ?? ''), ], 'prefix' => $stateConfig['db_prefix'] ?? '', ], 'app' => [ 'key' => $appKey, ], 'created_at' => date('c'), ]; // Read 커넥션이 별도로 지정된 경우만 포함 (그렇지 않으면 Laravel 이 write 사용) if (! empty($stateConfig['db_read_host']) && $stateConfig['db_read_host'] !== ($stateConfig['db_write_host'] ?? null)) { $runtime['db']['read'] = [ 'host' => $stateConfig['db_read_host'], 'port' => $stateConfig['db_read_port'] ?? $runtime['db']['write']['port'], 'database' => $stateConfig['db_read_database'] ?? $runtime['db']['write']['database'], 'username' => $stateConfig['db_read_username'] ?? $runtime['db']['write']['username'], 'password' => $stateConfig['db_read_password'] ?? $runtime['db']['write']['password'], ]; } return $runtime; } }