cachedComposerBinary !== null) { return $this->cachedComposerBinary ?: null; } $candidates = array_filter([ $hint, config('process.composer_binary'), $_ENV['COMPOSER_BINARY'] ?? null, getenv('COMPOSER_BINARY') ?: null, ]); foreach ($candidates as $candidate) { if ($this->isExecutableCandidate($candidate)) { return $this->cachedComposerBinary = $candidate; } } $found = $this->searchComposerInPath(); if ($found !== null) { return $this->cachedComposerBinary = $found; } // composer.phar 폴백 // @is_file 로 open_basedir warning 억제 (BASE_PATH/getcwd 가 화이트리스트 밖일 가능성) $pharCandidates = [ base_path('composer.phar'), getcwd().DIRECTORY_SEPARATOR.'composer.phar', ]; foreach ($pharCandidates as $phar) { if (@is_file($phar)) { return $this->cachedComposerBinary = $phar; } } $this->cachedComposerBinary = ''; return null; } /** * composer 실행 가능 여부 종합 판단. * * proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0. * * @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용) * @return bool composer 가 현재 환경에서 실행 가능한지 여부 */ public function canExecuteComposer(?string $hint = null): bool { if ($this->cachedComposerExecutable !== null && $hint === null) { return $this->cachedComposerExecutable; } if (! $this->hasProcOpen()) { return $this->cachedComposerExecutable = false; } $binary = $this->findComposerBinary($hint); if ($binary === null) { return $this->cachedComposerExecutable = false; } try { $command = $this->buildComposerCommand($binary, ['--version', '--no-interaction']); $process = @proc_open( $command, [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $pipes, null, self::buildComposerEnv(), ['bypass_shell' => true] ); if (! is_resource($process)) { return $this->cachedComposerExecutable = false; } foreach ($pipes as $pipe) { if (is_resource($pipe)) { @fclose($pipe); } } $exit = proc_close($process); return $this->cachedComposerExecutable = ($exit === 0); } catch (\Throwable $e) { Log::warning('Composer 실행 가능 여부 확인 실패', [ 'error' => $e->getMessage(), 'binary' => $binary, ]); return $this->cachedComposerExecutable = false; } } /** * 지정 경로에 쓰기 가능한지 확인. * * @param string $targetPath vendor 디렉토리 경로 (존재하지 않아도 부모 디렉토리 쓰기 권한 검사) * @return bool 부모 디렉토리가 존재하고 쓰기 가능한지 여부 */ public function canWriteVendor(string $targetPath): bool { $parent = dirname($targetPath); return is_dir($parent) && is_writable($parent); } /** * 인스톨러 Step 2 요구사항 체크용 종합 리포트. * * @param string|null $hint composer 바이너리 경로 힌트 (캐시 우회용) * @return array{ * proc_open: bool, * shell_exec: bool, * zip_archive: bool, * composer_binary: string|null, * composer_executable: bool, * can_use_composer: bool, * can_use_bundle: bool, * } */ public function summarize(?string $hint = null): array { $composerBinary = $this->findComposerBinary($hint); $composerExecutable = $this->canExecuteComposer($hint); $zipAvailable = $this->hasZipArchive(); return [ 'proc_open' => $this->hasProcOpen(), 'shell_exec' => $this->hasShellExec(), 'zip_archive' => $zipAvailable, 'composer_binary' => $composerBinary, 'composer_executable' => $composerExecutable, 'can_use_composer' => $composerExecutable, 'can_use_bundle' => $zipAvailable, ]; } /** * 캐시 초기화 (테스트용). */ public function resetCache(): void { $this->cachedComposerExecutable = null; $this->cachedComposerBinary = null; } /** * proc_open() 5번째 인자용 composer 환경변수 배열을 구성합니다. * * root/super user 환경 + 비대화형(웹) 컨텍스트에서 composer 가 interactive * 경고를 출력하며 비정상 종료하는 문제를 차단합니다. * * 현재 프로세스의 환경변수(getenv() + $_ENV)에 두 composer 전용 변수를 마지막에 * 덮어쓰기 — 외부에서 0 으로 설정되어 있어도 강제로 1. * * @return array */ public static function buildComposerEnv(): array { $current = getenv(); if (! is_array($current)) { $current = []; } return array_merge($current, $_ENV, [ 'COMPOSER_ALLOW_SUPERUSER' => '1', 'COMPOSER_NO_INTERACTION' => '1', ]); } /** * composer 실행 명령 문자열 구성. * * 반환된 문자열은 `proc_open(..., ['bypass_shell' => true])` 와 함께 사용해야 한다. * Windows 에서 `bypass_shell` 없이 실행하면 `cmd.exe` 가 따옴표로 시작하는 `.bat` * 경로의 앞뒤 따옴표를 벗겨 첫 인자(`install` 등)를 실행 파일로 오인한다. * * @param string $binary composer 바이너리 경로 (findComposerBinary 반환값) * @param array $args composer 하위 명령과 옵션 (예: ['install', '--no-dev']) * @return string proc_open 에 넘길 실행 명령 문자열 */ public function buildComposerCommand(string $binary, array $args): string { $escaped = array_map('escapeshellarg', $args); if (str_contains($binary, ' ')) { // 전체 명령어로 취급 return $binary.' '.implode(' ', $escaped); } if (str_ends_with(strtolower($binary), '.phar')) { $phpBinary = config('process.php_binary', 'php'); return escapeshellarg($phpBinary).' '.escapeshellarg($binary).' '.implode(' ', $escaped); } return escapeshellarg($binary).' '.implode(' ', $escaped); } /** * PATH 환경변수에서 composer 검색. * * stat (is_file) 은 open_basedir 같은 PHP 런타임 제약 환경에서 false negative * 를 일으키므로 보조 신호로만 쓴다. stat 통과 후보가 있으면 우선 반환하고, * 그렇지 않은 경우 메타문자 없는 첫 후보를 반환해 canExecuteComposer 의 * proc_open 결과로 최종 판정한다. */ private function searchComposerInPath(): ?string { $pathEnv = getenv('PATH') ?: ($_SERVER['PATH'] ?? ''); if (empty($pathEnv)) { return null; } $separator = DIRECTORY_SEPARATOR === '\\' ? ';' : ':'; $paths = explode($separator, $pathEnv); $names = DIRECTORY_SEPARATOR === '\\' ? ['composer.bat', 'composer.exe', 'composer.phar', 'composer'] : ['composer', 'composer.phar']; $fallback = null; foreach ($paths as $dir) { foreach ($names as $name) { $candidate = rtrim($dir, '/\\').DIRECTORY_SEPARATOR.$name; // stat 통과 후보가 있으면 우선 반환 (가장 신뢰도 높음) if (@is_file($candidate)) { return $candidate; } // stat 실패 후보는 첫 번째만 fallback 으로 보관. // open_basedir 환경에서는 정상 binary 도 is_file false 가 되므로 // proc_open 결과로 최종 판정할 기회를 남긴다. if ($fallback === null) { $fallback = $candidate; } } } return $fallback; } /** * 후보 경로가 실행 가능한 파일인지 확인. * * stat (is_file) 의존을 제거해 open_basedir 환경의 false negative 를 피한다. * 단일 토큰의 셸 메타문자만 차단하고, 실제 실행 가능 여부는 canExecuteComposer * 의 proc_open 결과로 최종 판정한다. */ private function isExecutableCandidate(?string $candidate): bool { if ($candidate === null || $candidate === '') { return false; } // 공백 포함 시 전체 커맨드로 간주 — 외부 신뢰된 config/.env 값을 그대로 사용. // buildComposerCommand 의 공백 분기와 동일한 신뢰 모델 (운영자 자기 책임 영역). if (str_contains($candidate, ' ')) { return true; } // 셸 메타문자 + 제어문자 차단. 백슬래시는 Windows 경로 구분자이므로 차단 대상 아님 — // 셸 인젝션 차단은 호출자의 escapeshellarg/buildComposerCommand 가 담당. if (preg_match('/[;`$|<>"\'&\x00-\x1F]/', $candidate)) { return false; } return true; } }