이커머스 알림 정의 화면에서 order_pending_deposit·order_delivered 두 알림이 라벨 대신 raw 다국어 키로 표시되던 문제를 수정. settings.json 3로케일에 라벨 추가 + 렌더 회귀 테스트 + 브라우저 실측. vendor-bundle 시스템 결함 5종 해결: - (A/B) manifest 해시 기준을 활성이 아닌 출력(_bundled) 디렉토리로 통일해 확장 업데이트가 composer_json_sha_mismatch 순환에 빠지던 문제 해소 - (C) 코어 빌드 시 composer autoload dump 의 async 손자 프로세스가 부모 파이프 핸들을 상속·점유해 무한 대기하던 교착을, stdout/stderr 를 파이프 아닌 파일 descriptor 로 받아 원천 차단 (파이프=행/파일=완주 A/B 실측으로 확정) - (D) 빌드 실패 시 원본 번들 유실 방지 — 임시파일 성공 후 원자적 교체 - (E) Windows .bat 실행 시 bypass_shell 통일 코어 7.0.4 / 이커머스·언어팩 1.0.3 bump.
355 lines
12 KiB
PHP
355 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Extension\Vendor;
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
/**
|
|
* 런타임 환경에서 composer 실행 가능 여부 및 ZipArchive 사용 가능 여부를 감지합니다.
|
|
*
|
|
* 공유 호스팅 환경(proc_open 차단, composer 미설치)에서 vendor 번들 모드로
|
|
* 자동 폴백하기 위한 판단 근거를 제공합니다.
|
|
*/
|
|
class EnvironmentDetector
|
|
{
|
|
/**
|
|
* 캐시된 composer 실행 가능 여부.
|
|
*/
|
|
private ?bool $cachedComposerExecutable = null;
|
|
|
|
/**
|
|
* 캐시된 composer 바이너리 경로.
|
|
*/
|
|
private ?string $cachedComposerBinary = null;
|
|
|
|
/**
|
|
* proc_open() 함수 사용 가능 여부.
|
|
*
|
|
* @return bool proc_open 호출 가능 여부 (disable_functions 차단 시 false)
|
|
*/
|
|
public function hasProcOpen(): bool
|
|
{
|
|
if (! function_exists('proc_open')) {
|
|
return false;
|
|
}
|
|
|
|
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
|
|
|
return ! in_array('proc_open', $disabled, true);
|
|
}
|
|
|
|
/**
|
|
* shell_exec() 함수 사용 가능 여부.
|
|
*
|
|
* @return bool shell_exec 호출 가능 여부 (disable_functions 차단 시 false)
|
|
*/
|
|
public function hasShellExec(): bool
|
|
{
|
|
if (! function_exists('shell_exec')) {
|
|
return false;
|
|
}
|
|
|
|
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
|
|
|
return ! in_array('shell_exec', $disabled, true);
|
|
}
|
|
|
|
/**
|
|
* ZipArchive 클래스 사용 가능 여부.
|
|
*
|
|
* @return bool ext-zip 활성화 여부
|
|
*/
|
|
public function hasZipArchive(): bool
|
|
{
|
|
return class_exists(\ZipArchive::class);
|
|
}
|
|
|
|
/**
|
|
* composer 바이너리 경로 찾기.
|
|
*
|
|
* 우선순위: hint → config(process.composer_binary) → $_ENV['COMPOSER_BINARY']
|
|
* → PATH 검색 → composer.phar
|
|
*
|
|
* @param string|null $hint 바이너리 경로 힌트 (null 이면 config/ENV/PATH 순으로 자동 탐색)
|
|
* @return string|null 발견된 composer 바이너리 경로. 없으면 null
|
|
*/
|
|
public function findComposerBinary(?string $hint = null): ?string
|
|
{
|
|
if ($this->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<string, string>
|
|
*/
|
|
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<int, string> $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;
|
|
}
|
|
}
|