diff --git a/.env.example b/.env.example
index 2057cd34..4b1986af 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.0-beta.6
+APP_VERSION=7.0.0-beta.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 509844ba..21c692a6 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.0-beta.6
+APP_VERSION=7.0.0-beta.7
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50e0e3a4..7a2bb7b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,30 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.0-beta.7] - 2026-05-15
+
+### Fixed
+
+- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)
+- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.
+- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.
+- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.
+- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.
+- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.
+
+### Changed
+
+- `INSTALL.md` 에 "업그레이드" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)
+- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)
+
+### Notes — `.env` 권한 권장안
+
+위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.
+
+- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.
+- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
+- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.
+
## [7.0.0-beta.6] - 2026-05-14
### Fixed
diff --git a/INSTALL.md b/INSTALL.md
index b67b5651..aa934092 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.0-beta.6 g7
+# (필요 시) mv g7-7.0.0-beta.7 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
@@ -338,6 +338,157 @@ http://도메인/install
---
+## 업그레이드
+
+이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요.
+
+### 개요
+
+그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다.
+
+```bash
+sudo php artisan core:update
+```
+
+이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다.
+
+- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP)
+- 자동 백업 생성 (실패 시 자동 롤백)
+- 유지보수 모드 자동 진입
+- 데이터베이스 마이그레이션 실행
+- 업그레이드 스텝 실행 (버전별 데이터 보정)
+- 번들 확장 자동 재동기화
+- 캐시 정리 및 유지보수 모드 해제
+
+### 업그레이드 사전 준비
+
+업그레이드 전 다음을 확인하세요.
+
+1. **데이터베이스 백업** (필수)
+
+ ```bash
+ mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql
+ ```
+
+2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요.
+
+ ```bash
+ tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs
+ ```
+
+3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다.
+
+### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점)
+
+운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다.
+
+1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동
+2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시
+3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭
+4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행**
+
+> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다.
+
+### 방법 2: CLI 자동 업그레이드 (표준 절차)
+
+대다수 환경에서 권장되는 절차입니다.
+
+```bash
+sudo php artisan core:update
+```
+
+#### `sudo` 가 권장되는 이유
+
+업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다.
+
+- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등)
+- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신
+- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성
+- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화
+
+`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다.
+
+#### `sudo` 없이 실행해도 되는 환경
+
+- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행
+- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행
+
+> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다.
+
+### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경)
+
+PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다.
+
+```bash
+# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제
+wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip
+unzip <파일명>.zip -d /tmp/g7-new
+
+# 2. 압축 해제된 경로를 지정하여 업데이트 실행
+sudo php artisan core:update --source=/tmp/g7-new
+```
+
+`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다.
+
+### 사용자군별 단계적 업그레이드
+
+여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다.
+
+| 현재 버전 | 권장 경로 |
+|----------|----------|
+| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 |
+| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 |
+| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 |
+| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 |
+
+특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다.
+
+> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요.
+
+### 권한/캐시 트러블슈팅
+
+업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다.
+
+#### `.env` 읽기 실패 (`Permission denied`)
+
+```bash
+ls -l .env
+# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자
+```
+
+대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다.
+
+#### `bootstrap/cache` 쓰기 실패
+
+```bash
+sudo chown -R www-data:www-data bootstrap/cache storage
+```
+
+소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다.
+
+#### 업그레이드 도중 자동 롤백 후 부팅 실패
+
+beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다.
+
+```bash
+# 진단 모드 (실제 삭제 없음)
+sudo php artisan hotfix:rollback-stale-files
+
+# 운영자 확인 후 실제 정리
+sudo php artisan hotfix:rollback-stale-files --prune
+```
+
+### 업그레이드 후 확인
+
+업그레이드가 끝나면 다음을 점검합니다.
+
+1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인
+2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인
+3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인
+4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인
+
+---
+
## 프로덕션 환경 추가 설정
프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다.
@@ -346,6 +497,30 @@ http://도메인/install
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
+### `.env` 권한 강화 (선택)
+
+`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
+
+- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
+
+- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우):
+
+ ```bash
+ sudo chown www-data:www-data .env
+ sudo chmod 600 .env
+ ```
+
+- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우):
+
+ ```bash
+ sudo chgrp www-data .env
+ sudo chmod 640 .env
+ ```
+
+ 웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다.
+
+인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다.
+
### 데몬 프로세스
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
diff --git a/README.md b/README.md
index 6ff3f43e..ca206c54 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
diff --git a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
index eb42c6a9..ee1858cd 100644
--- a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
+++ b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
@@ -459,4 +459,5 @@ trait BundledExtensionUpdatePrompt
'has_updates' => true,
];
}
+
}
diff --git a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php
index d48df1ff..66dfb893 100644
--- a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php
+++ b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php
@@ -174,4 +174,5 @@ class ExecuteBundledUpdatesCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
+
}
diff --git a/app/Providers/InstallerRuntimeServiceProvider.php b/app/Providers/InstallerRuntimeServiceProvider.php
index 02c80949..b0ddf1e5 100644
--- a/app/Providers/InstallerRuntimeServiceProvider.php
+++ b/app/Providers/InstallerRuntimeServiceProvider.php
@@ -40,6 +40,45 @@ class InstallerRuntimeServiceProvider extends ServiceProvider
$runtimePath = base_path(self::RUNTIME_PATH_RELATIVE);
if (! is_file($runtimePath)) {
+ // beta.7 한시 보정 — 이미 finalize 완료된 환경의 stale process ENV 회귀 차단.
+ //
+ // 배경 (이슈 #371): beta.4~6 손상 환경에서 beta.7 코어 업그레이드의 spawn 자식 1
+ // (core:execute-upgrade-steps) 이 .env 머지 + runtime.php 삭제를 마쳐도, 부모
+ // 프로세스의 process ENV (getenv / $_ENV / $_SERVER) 는 부팅 시점의 stale 값
+ // (.env.example fallback: DB_WRITE_USERNAME=root) 그대로 유지된다. 그 직후 부모가
+ // 띄우는 spawn 자식 2 (core:execute-bundled-updates) 에 stale ENV 가 그대로
+ // 상속되어 Laravel Dotenv::createImmutable() 이 디스크의 정합 .env 값을 보지 못해
+ // env('DB_WRITE_USERNAME') 이 stale 'root' 반환 → CoreServiceProvider::boot() 의
+ // isDatabaseConnectionValid() 가드가 트리거되어 확장 로딩 skip + ERROR 출력 →
+ // 번들 일괄 업데이트 전체 실패.
+ //
+ // 본 보정은 자식 2 가 부팅할 때 InstallerRuntimeServiceProvider::register() (가장 먼저
+ // 실행되는 ServiceProvider) 안에서 다음 4개 조건을 모두 만족하면 디스크 .env 를
+ // 직접 파싱하여 process ENV 를 갱신한다:
+ // (1) runtime.php 부재 (이미 finalize 완료된 상태 — 본 분기에 도달)
+ // (2) .env 존재
+ // (3) .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호)
+ // (4) .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 빈 값 아님)
+ // AND process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값)
+ //
+ // 정상 환경에서는 (4) 의 process ENV stale 시그니처가 false 이므로 보정 미발동.
+ $this->recoverStaleProcessEnvFromDiskEnv();
+
+ // 본 PR 부수효과 정리 — beta.7 업그레이드 흐름의 모든 root 권한 자식 spawn
+ // 부팅마다 자동으로 storage/framework/cache/data/ 하위 root 소유 디렉토리를
+ // 즉시 chown. 자식 1 (FinalizeOrphanedInstallerRuntime) 종료 후에 자식 2~N
+ // (번들 update / 후속 자식들) 이 root 권한으로 캐시 쓰기를 트리거하더라도
+ // 그 자식 자체의 ServiceProvider 부팅 시점에 본 보정이 발화하여 정리.
+ //
+ // 가드:
+ // (1) runtime.php 부재 (이미 finalize 완료 — 본 분기에 도달)
+ // (2) 디스크 config/app.php 의 version === '7.0.0-beta.7' (recover 와 동일 가드)
+ // (3) posix_geteuid() === 0 (root 권한 자식만)
+ //
+ // 정상 환경(beta.3 / beta.8+) 또는 PHP-FPM 워커(www-data 권한) 는 (2) 또는 (3)
+ // 가드로 자동 비활성화.
+ $this->normalizeRootOwnedCacheDirs();
+
return;
}
@@ -53,6 +92,328 @@ class InstallerRuntimeServiceProvider extends ServiceProvider
$this->applyAppKey($runtime);
}
+ /**
+ * beta.7 한시 보정 — 디스크 .env 가 정합이고 process ENV 가 stale 일 때만 갱신.
+ *
+ * 트리거 조건이 매우 보수적이라 정상 환경 부팅 비용은 .env 의 parseEnvFile 1회 + 키 4~5개
+ * 비교. 손상 환경에서만 putenv/$_ENV/$_SERVER 갱신이 일어남.
+ */
+ private function recoverStaleProcessEnvFromDiskEnv(): void
+ {
+ // beta.7 한시 가드 — 본 보정은 beta.4~6 finalize 결함 환경의 회귀 차단 전용.
+ // 디스크 config/app.php 의 'version' 값을 라인 매칭으로 추출 (메모리/env 무관)
+ // 하여 정확히 beta.7 로 업그레이드 중인 경우에만 발화. 메모리 config('app.version')
+ // 은 부모 stale ENV 의 APP_VERSION 을 따라가므로 신뢰할 수 없음.
+ $coreVersion = $this->readCoreVersionFromDisk();
+ if ($coreVersion !== '7.0.0-beta.7') {
+ return;
+ }
+
+ $envPath = base_path('.env');
+
+ if (! is_file($envPath)) {
+ return;
+ }
+
+ // parse_ini_file 은 .env 가 PHP INI 와 100% 호환되지 않는 케이스 (예약어 값,
+ // 일부 특수문자) 에서 실패 가능. 라인 단위 직접 파싱으로 호환성 함정 회피.
+ $parsed = $this->parseEnvFile($envPath);
+
+ if (! is_array($parsed) || $parsed === []) {
+ return;
+ }
+
+ // 조건 (3): INSTALLER_COMPLETED 가 truthy 여야 함 (finalize 완료 신호)
+ $completedFlagRaw = (string) ($parsed['INSTALLER_COMPLETED'] ?? '');
+ $completedFlag = strtolower(trim($completedFlagRaw, " \t\"'"));
+ if (! in_array($completedFlag, ['true', '1', 'yes'], true)) {
+ return;
+ }
+
+ // 조건 (4-a): .env 의 DB_WRITE_USERNAME 이 정합 (root 아님 + 비어있지 않음)
+ $diskUsernameRaw = (string) ($parsed['DB_WRITE_USERNAME'] ?? '');
+ $diskUsername = $this->unwrapEnvValue($diskUsernameRaw);
+ if ($diskUsername === '' || $diskUsername === 'root') {
+ return;
+ }
+
+ // 조건 (4-b): process ENV 의 DB_WRITE_USERNAME 이 stale (root 또는 빈 값).
+ //
+ // bootstrap/app.php 의 Env::disablePutenv() 로 인해 getenv 가 빈 값일 수 있으므로,
+ // getenv / $_ENV / $_SERVER 셋 중 어느 하나라도 정합 (root 아님 + 빈 값 아님) 이면
+ // 정상 환경으로 판정하여 보정 skip. 셋 다 stale (root 또는 빈) 일 때만 진입.
+ $envUsername = (string) getenv('DB_WRITE_USERNAME');
+ $envUsernameSE = (string) ($_ENV['DB_WRITE_USERNAME'] ?? '');
+ $serverUsernameSE = (string) ($_SERVER['DB_WRITE_USERNAME'] ?? '');
+ foreach ([$envUsername, $envUsernameSE, $serverUsernameSE] as $candidate) {
+ if ($candidate !== '' && $candidate !== 'root') {
+ return;
+ }
+ }
+
+ // 4개 조건 모두 만족 — process ENV 갱신
+ $refreshableKeys = [
+ 'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
+ 'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
+ 'DB_PREFIX', 'APP_KEY',
+ ];
+
+ foreach ($refreshableKeys as $key) {
+ if (! array_key_exists($key, $parsed)) {
+ continue;
+ }
+ $value = $this->unwrapEnvValue((string) $parsed[$key]);
+ putenv($key.'='.$value);
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ }
+
+ // Laravel Config 도 동기 갱신 — register() 시점에 LoadConfiguration 이 이미 실행되어
+ // process ENV 만 갱신해도 config('database.connections.mysql.*') 가 stale 값
+ // (root) 으로 캐시된 상태이기 때문에 직접 덮어써야 한다.
+ $this->applyConfigFromParsedEnv($parsed);
+ }
+
+ /**
+ * recover 분기 — 파싱된 .env 의 DB_* / APP_KEY / DB_PREFIX 를 Laravel Config 에 적용.
+ *
+ * 기존 `applyDatabaseConfig` 와 동등한 키 경로 / 구조 (`database.connections.mysql.{read,write}.*`).
+ *
+ * @param array $parsed
+ */
+ private function applyConfigFromParsedEnv(array $parsed): void
+ {
+ $writeHost = $parsed['DB_WRITE_HOST'] ?? null;
+ if ($writeHost !== null && $writeHost !== '') {
+ Config::set('database.connections.mysql.write.host', [$writeHost]);
+ Config::set('database.connections.mysql.write.port', $parsed['DB_WRITE_PORT'] ?? '3306');
+ Config::set('database.connections.mysql.write.database', $parsed['DB_WRITE_DATABASE'] ?? '');
+ Config::set('database.connections.mysql.write.username', $parsed['DB_WRITE_USERNAME'] ?? '');
+ Config::set('database.connections.mysql.write.password', $parsed['DB_WRITE_PASSWORD'] ?? '');
+ }
+
+ $readHost = $parsed['DB_READ_HOST'] ?? null;
+ if ($readHost !== null && $readHost !== '') {
+ Config::set('database.connections.mysql.read.host', [$readHost]);
+ Config::set('database.connections.mysql.read.port', $parsed['DB_READ_PORT'] ?? '3306');
+ Config::set('database.connections.mysql.read.database', $parsed['DB_READ_DATABASE'] ?? '');
+ Config::set('database.connections.mysql.read.username', $parsed['DB_READ_USERNAME'] ?? '');
+ Config::set('database.connections.mysql.read.password', $parsed['DB_READ_PASSWORD'] ?? '');
+ } elseif ($writeHost !== null && $writeHost !== '') {
+ // read 가 별도 지정되지 않은 경우 write 값으로 동기화 (단일 DB 시나리오)
+ Config::set('database.connections.mysql.read.host', [$writeHost]);
+ Config::set('database.connections.mysql.read.port', $parsed['DB_WRITE_PORT'] ?? '3306');
+ Config::set('database.connections.mysql.read.database', $parsed['DB_WRITE_DATABASE'] ?? '');
+ Config::set('database.connections.mysql.read.username', $parsed['DB_WRITE_USERNAME'] ?? '');
+ Config::set('database.connections.mysql.read.password', $parsed['DB_WRITE_PASSWORD'] ?? '');
+ }
+
+ if (isset($parsed['DB_PREFIX'])) {
+ Config::set('database.connections.mysql.prefix', $parsed['DB_PREFIX']);
+ }
+
+ $appKey = $parsed['APP_KEY'] ?? null;
+ if (is_string($appKey) && $appKey !== '') {
+ Config::set('app.key', $appKey);
+ }
+
+ // 기존 connection 인스턴스의 stale credential 캐시 폐기 — 다음 DB::connection() 호출 시
+ // 새 Config 로 재인스턴스화되도록 강제. config.write.username 이 stale root 로
+ // 남아 있던 회귀의 부속 원인 차단 (DB Manager 의 connection 캐시).
+ try {
+ \Illuminate\Support\Facades\DB::purge('mysql');
+ } catch (\Throwable $e) {
+ // non-fatal — Config 만 갱신된 상태로도 다음 connection 시 정합 값 사용
+ }
+ }
+
+ /**
+ * beta.7 업그레이드 자식 spawn 부팅마다 자동 발동 — storage/framework/cache/data/
+ * 하위 root 소유 디렉토리/파일을 정상 owner/group 으로 chown.
+ *
+ * 본 PR 의 recover 가 발화하면서 부수적으로 만들 수 있는 root 권한 캐시 디렉토리를
+ * 자식 자체의 부팅 시점에 자동 청소한다. 안전망 역할 — recover 가 정상 발화하여
+ * loadModules 가 정상 권한 캐시를 생성하면 본 메서드는 found=0 으로 no-op.
+ *
+ * 가드:
+ * - 디스크 config/app.php version === '7.0.0-beta.7' (recover 와 동일)
+ * - posix_geteuid() === 0 (root 권한 자식만 — 운영자 의도 권한 보존)
+ * - cache/data 자신이 정합 owner/group (chown 기준값 추정)
+ */
+ private function normalizeRootOwnedCacheDirs(): void
+ {
+ // beta.7 한정 — recover 와 동일 가드
+ $coreVersion = $this->readCoreVersionFromDisk();
+ if ($coreVersion !== '7.0.0-beta.7') {
+ return;
+ }
+
+ if (! function_exists('posix_geteuid') || ! function_exists('chown') || ! function_exists('chgrp')) {
+ return;
+ }
+
+ // root 권한 프로세스만 (PHP-FPM 워커는 www-data → euid != 0 → skip)
+ if (posix_geteuid() !== 0) {
+ return;
+ }
+
+ $cacheRoot = base_path('storage/framework/cache/data');
+ if (! is_dir($cacheRoot)) {
+ return;
+ }
+
+ // 정상 owner/group 추정 (cache/data 자신이 SSoT)
+ $targetUid = @fileowner($cacheRoot);
+ $targetGid = @filegroup($cacheRoot);
+ if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) {
+ // cache/data 자신이 root 소유면 추정 실패 — bootstrap/cache fallback
+ $bootstrapCache = base_path('bootstrap/cache');
+ if (is_dir($bootstrapCache)) {
+ $targetUid = @fileowner($bootstrapCache);
+ $targetGid = @filegroup($bootstrapCache);
+ }
+ if (! is_int($targetUid) || ! is_int($targetGid) || $targetUid === 0) {
+ return;
+ }
+ }
+
+ // 1depth 만 빠르게 점검 — root 소유 디렉토리 발견 시 재귀 chown
+ $handle = @opendir($cacheRoot);
+ if ($handle === false) {
+ return;
+ }
+ while (($name = readdir($handle)) !== false) {
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ $path = $cacheRoot.DIRECTORY_SEPARATOR.$name;
+ $uid = @fileowner($path);
+ $gid = @filegroup($path);
+ if ($uid !== 0 && $gid !== 0) {
+ continue;
+ }
+ // root 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존
+ $this->chownRecursive($path, $targetUid, $targetGid);
+ }
+ closedir($handle);
+ }
+
+ /**
+ * 디렉토리/파일을 재귀적으로 chown + chgrp.
+ *
+ * @return bool 모든 항목 성공 시 true
+ */
+ private function chownRecursive(string $path, int $uid, int $gid): bool
+ {
+ $ok = @chown($path, $uid) && @chgrp($path, $gid);
+ if (! is_dir($path) || is_link($path)) {
+ return $ok;
+ }
+ $iter = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($iter as $entry) {
+ /** @var \SplFileInfo $entry */
+ $childPath = $entry->getPathname();
+ $ok = (@chown($childPath, $uid) && @chgrp($childPath, $gid)) && $ok;
+ }
+
+ return $ok;
+ }
+
+ /**
+ * 디스크 `config/app.php` 의 `'version' => ...` 라인을 정규식 매칭으로 추출.
+ *
+ * 메모리 config('app.version') 은 부모 stale ENV 의 APP_VERSION 을 따르므로
+ * 본 fix 의 자기 활성화 가드로 사용 불가. 디스크 SSoT (config/app.php) 직접 읽기로
+ * 코어 업그레이드 시점의 실제 적용 버전 확보.
+ *
+ * @return string|null 추출 실패 시 null
+ */
+ private function readCoreVersionFromDisk(): ?string
+ {
+ $configPath = base_path('config/app.php');
+ if (! is_file($configPath)) {
+ return null;
+ }
+ $content = @file_get_contents($configPath);
+ if ($content === false) {
+ return null;
+ }
+ // 'version' => env('APP_VERSION', '7.0.0-beta.7'),
+ if (preg_match("/'version'\s*=>\s*env\(\s*'APP_VERSION'\s*,\s*'([^']+)'\s*\)/", $content, $m)) {
+ return $m[1];
+ }
+ // fallback: 'version' => '7.0.0-beta.7',
+ if (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $m)) {
+ return $m[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * .env 파일을 라인 단위로 직접 파싱하여 key=>value 배열 반환.
+ *
+ * parse_ini_file 의 PHP INI 호환성 함정을 회피하기 위해 자체 파싱:
+ * - 빈 줄 / `#` 또는 `;` 로 시작하는 주석 줄 무시
+ * - 첫 `=` 기준으로 key/value 분할 (값에 `=` 포함 가능: APP_KEY=base64:...= 등)
+ * - 외곽 인용부호 (single/double) 제거 — unwrapEnvValue 사용
+ * - 키 이름은 [A-Za-z_][A-Za-z0-9_]* 만 인정
+ *
+ * @return array
+ */
+ private function parseEnvFile(string $envPath): array
+ {
+ $content = @file_get_contents($envPath);
+ if ($content === false) {
+ return [];
+ }
+
+ $lines = preg_split('/\r\n|\r|\n/', $content);
+ if ($lines === false) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($lines as $line) {
+ $trimmed = ltrim($line);
+ if ($trimmed === '' || $trimmed[0] === '#' || $trimmed[0] === ';') {
+ continue;
+ }
+ $eqPos = strpos($trimmed, '=');
+ if ($eqPos === false || $eqPos === 0) {
+ continue;
+ }
+ $key = rtrim(substr($trimmed, 0, $eqPos));
+ if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key)) {
+ continue;
+ }
+ $value = substr($trimmed, $eqPos + 1);
+ // 줄 끝의 행간 공백 제거 (인용된 값은 인용부호 안의 내용 보존)
+ $value = rtrim($value, " \t");
+ $value = $this->unwrapEnvValue($value);
+ $result[$key] = $value;
+ }
+
+ return $result;
+ }
+
+ private function unwrapEnvValue(string $value): string
+ {
+ $length = strlen($value);
+ if ($length >= 2) {
+ $first = $value[0];
+ $last = $value[$length - 1];
+ if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
+ return substr($value, 1, $length - 2);
+ }
+ }
+
+ return $value;
+ }
+
/**
* runtime 배열의 DB 자격증명을 config('database.connections.mysql.*') 에 주입.
*
diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php
index cea83396..f84f704a 100644
--- a/app/Services/CoreUpdateService.php
+++ b/app/Services/CoreUpdateService.php
@@ -95,7 +95,7 @@ class CoreUpdateService
$log = function (string $level, string $message) use ($logger): void {
if ($logger === null) {
- Log::$level($message);
+ Log::channel('upgrade')->$level($message);
return;
}
@@ -109,7 +109,7 @@ class CoreUpdateService
return;
}
- Log::$level($message);
+ Log::channel('upgrade')->$level($message);
};
$log('info', sprintf(
@@ -336,7 +336,7 @@ class CoreUpdateService
File::put($cachePath, $content);
}
} catch (\Exception $e) {
- Log::warning('원격 CHANGELOG 캐시 실패', ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->warning('원격 CHANGELOG 캐시 실패', ['error' => $e->getMessage()]);
}
}
@@ -404,7 +404,7 @@ class CoreUpdateService
->connectTimeout(5)
->get($apiUrl);
} catch (ConnectionException $e) {
- Log::warning(__('settings.core_update.log_api_call_failed'), [
+ Log::channel('upgrade')->warning(__('settings.core_update.log_api_call_failed'), [
'url' => $apiUrl,
'error' => $e->getMessage(),
]);
@@ -417,7 +417,7 @@ class CoreUpdateService
$apiMessage = is_array($data) && isset($data['message']) ? $data['message'] : '';
if ($statusCode === 401 || $statusCode === 403) {
- Log::warning(__('settings.core_update.log_auth_failed'), [
+ Log::channel('upgrade')->warning(__('settings.core_update.log_auth_failed'), [
'url' => $apiUrl,
'status' => $statusCode,
'has_token' => $token !== '',
@@ -436,7 +436,7 @@ class CoreUpdateService
if ($repoExists) {
// 저장소는 존재하지만 릴리스가 없음
- Log::info(__('settings.core_update.log_not_found'), [
+ Log::channel('upgrade')->info(__('settings.core_update.log_not_found'), [
'url' => $apiUrl,
'reason' => 'no_releases',
]);
@@ -445,7 +445,7 @@ class CoreUpdateService
}
// 저장소 자체를 찾을 수 없음
- Log::warning(__('settings.core_update.log_not_found'), [
+ Log::channel('upgrade')->warning(__('settings.core_update.log_not_found'), [
'url' => $apiUrl,
'has_token' => $token !== '',
'api_message' => $apiMessage,
@@ -458,7 +458,7 @@ class CoreUpdateService
}
if ($statusCode !== 200) {
- Log::warning(__('settings.core_update.log_unexpected_status'), [
+ Log::channel('upgrade')->warning(__('settings.core_update.log_unexpected_status'), [
'url' => $apiUrl,
'status' => $statusCode,
'api_message' => $apiMessage,
@@ -473,7 +473,7 @@ class CoreUpdateService
return ['version' => null, 'error' => __('settings.core_update.no_releases_found')];
} catch (\Exception $e) {
- Log::error(__('settings.core_update.log_version_check_error'), ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->error(__('settings.core_update.log_version_check_error'), ['error' => $e->getMessage()]);
return ['version' => null, 'error' => __('settings.core_update.github_api_failed')];
}
@@ -971,7 +971,7 @@ class CoreUpdateService
}
$dest = base_path($name);
- Log::warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [
+ Log::channel('upgrade')->warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [
'name' => $name,
'reason' => 'parent process targets list did not include this path; falling back to source auto-discovery',
]);
@@ -1113,7 +1113,7 @@ class CoreUpdateService
}
if (md5_file($pendingJson) !== md5_file($baseJson)) {
- Log::info('코어 업데이트: composer.json 변경 감지');
+ Log::channel('upgrade')->info('코어 업데이트: composer.json 변경 감지');
return false;
}
@@ -1123,20 +1123,20 @@ class CoreUpdateService
$baseLockExists = file_exists($baseLock);
if ($pendingLockExists !== $baseLockExists) {
- Log::info('코어 업데이트: composer.lock 존재 여부 불일치');
+ Log::channel('upgrade')->info('코어 업데이트: composer.lock 존재 여부 불일치');
return false;
}
if ($pendingLockExists && $baseLockExists) {
if (md5_file($pendingLock) !== md5_file($baseLock)) {
- Log::info('코어 업데이트: composer.lock 변경 감지');
+ Log::channel('upgrade')->info('코어 업데이트: composer.lock 변경 감지');
return false;
}
}
- Log::info('코어 업데이트: composer 의존성 변경 없음 — 스킵 가능');
+ Log::channel('upgrade')->info('코어 업데이트: composer 의존성 변경 없음 — 스킵 가능');
return true;
}
@@ -1238,7 +1238,7 @@ class CoreUpdateService
fclose($pipes[2]);
$exitCode = proc_close($process);
- Log::info('코어 업데이트: composer install 완료', [
+ Log::channel('upgrade')->info('코어 업데이트: composer install 완료', [
'working_dir' => $workingDir,
'exit_code' => $exitCode,
'output' => $output,
@@ -1410,7 +1410,7 @@ class CoreUpdateService
$definedRoleIdentifiers,
);
- Log::info('코어 역할/권한 동기화 완료', [
+ Log::channel('upgrade')->info('코어 역할/권한 동기화 완료', [
'stale_permissions_deleted' => $deletedPerms,
'stale_roles_deleted' => $deletedRoles,
]);
@@ -1447,7 +1447,7 @@ class CoreUpdateService
$currentSlugs,
);
- Log::info('코어 메뉴 동기화 완료', ['stale_deleted' => $deleted]);
+ Log::channel('upgrade')->info('코어 메뉴 동기화 완료', ['stale_deleted' => $deleted]);
}
/**
@@ -1486,14 +1486,14 @@ class CoreUpdateService
{
$path = config_path('core.php');
if (! File::exists($path)) {
- Log::warning('reloadCoreConfigAndResync: config/core.php 미존재 — 스킵');
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: config/core.php 미존재 — 스킵');
return;
}
$fresh = require $path;
if (! is_array($fresh)) {
- Log::warning('reloadCoreConfigAndResync: config/core.php 반환값이 배열이 아님 — 스킵');
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: config/core.php 반환값이 배열이 아님 — 스킵');
return;
}
@@ -1503,13 +1503,13 @@ class CoreUpdateService
try {
$this->syncCoreRolesAndPermissions();
} catch (\Throwable $e) {
- Log::warning('reloadCoreConfigAndResync: 권한 재동기화 실패', ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 권한 재동기화 실패', ['error' => $e->getMessage()]);
}
try {
$this->syncCoreMenus();
} catch (\Throwable $e) {
- Log::warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]);
}
try {
@@ -1517,7 +1517,7 @@ class CoreUpdateService
(new NotificationDefinitionSeeder())->run();
}
} catch (\Throwable $e) {
- Log::warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]);
}
try {
@@ -1525,7 +1525,7 @@ class CoreUpdateService
(new IdentityPolicySeeder())->run();
}
} catch (\Throwable $e) {
- Log::warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]);
}
try {
@@ -1533,7 +1533,7 @@ class CoreUpdateService
(new IdentityMessageDefinitionSeeder())->run();
}
} catch (\Throwable $e) {
- Log::warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]);
+ Log::channel('upgrade')->warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]);
}
}
@@ -1584,7 +1584,7 @@ class CoreUpdateService
);
}
- Log::warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.');
+ Log::channel('upgrade')->warning($message.' — fallback 모드. upgrade step 안에서 신규 메서드 호출 시 fatal 가능.');
}
$upgradesPath = base_path('upgrades');
@@ -1653,7 +1653,7 @@ class CoreUpdateService
foreach ($steps as $version => $step) {
$onStep?->__invoke($version);
- Log::info("코어 업그레이드 스텝 실행: {$version}");
+ Log::channel('upgrade')->info("코어 업그레이드 스텝 실행: {$version}");
$step->run($context->withCurrentStep($version));
}
}
@@ -1715,7 +1715,7 @@ class CoreUpdateService
'--refresh' => 15,
]);
- Log::info('코어 업데이트: 유지보수 모드 활성화', ['secret' => $secret]);
+ Log::channel('upgrade')->info('코어 업데이트: 유지보수 모드 활성화', ['secret' => $secret]);
return $secret;
}
@@ -1726,7 +1726,7 @@ class CoreUpdateService
public function disableMaintenanceMode(): void
{
Artisan::call('up');
- Log::info('코어 업데이트: 유지보수 모드 비활성화');
+ Log::channel('upgrade')->info('코어 업데이트: 유지보수 모드 비활성화');
}
/**
@@ -2017,7 +2017,7 @@ class CoreUpdateService
}
if ($truncated) {
- Log::warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [
+ Log::channel('upgrade')->warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [
'collected' => count($snapshot),
'paths' => $paths,
]);
@@ -2167,7 +2167,7 @@ class CoreUpdateService
$report = FilePermissionHelper::chownRecursiveDetailed($path, $owner, $group, respectPreservationMarker: true);
if ($report['changed'] > 0) {
- Log::info('코어 업데이트: 소유권 복원', [
+ Log::channel('upgrade')->info('코어 업데이트: 소유권 복원', [
'target' => $target,
'owner' => $owner,
'group' => $group,
@@ -2226,14 +2226,14 @@ class CoreUpdateService
}
if ($groupWritableChanged > 0) {
- Log::info('코어 업데이트: 그룹 쓰기 권한 정상화', [
+ Log::channel('upgrade')->info('코어 업데이트: 그룹 쓰기 권한 정상화', [
'targets' => $groupWritableTargets,
'changed_entries' => $groupWritableChanged,
]);
}
if ($restoredCount > 0) {
- Log::info('코어 업데이트: 소유권 복원 완료', [
+ Log::channel('upgrade')->info('코어 업데이트: 소유권 복원 완료', [
'restored_entries_total' => $restoredCount,
'targets' => $targets,
]);
@@ -2335,7 +2335,7 @@ class CoreUpdateService
}
if ($changed > 0) {
- Log::info('코어 업데이트: 항목별 정확 복원 완료', [
+ Log::channel('upgrade')->info('코어 업데이트: 항목별 정확 복원 완료', [
'snapshot_items' => count($detailedSnapshot),
'changed_attributes' => $changed,
]);
@@ -2348,7 +2348,7 @@ class CoreUpdateService
'failed' => $failed,
'failed_paths' => $failedPaths,
];
- Log::warning('코어 업데이트: 항목별 복원 부분 실패', [
+ Log::channel('upgrade')->warning('코어 업데이트: 항목별 복원 부분 실패', [
'failed_count' => $failed,
'first_failed' => $failedPaths[0] ?? null,
]);
@@ -2389,7 +2389,7 @@ class CoreUpdateService
File::put($reportPath, $content);
- Log::error('코어 업데이트 실패', [
+ Log::channel('upgrade')->error('코어 업데이트 실패', [
'from' => $fromVersion,
'to' => $toVersion,
'error' => $exception->getMessage(),
diff --git a/config/app.php b/config/app.php
index d5dfd02e..d5de7e39 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.0-beta.6'),
+ 'version' => env('APP_VERSION', '7.0.0-beta.7'),
/*
|--------------------------------------------------------------------------
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index 31d4a144..348bd605 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.0-beta.2] - 2026-05-15
+
+### Added
+
+- 코어 업데이트 안내 모달의 신규 텍스트 5건에 대한 일본어 번역 추가 (`sudo` 권장 안내 / 외부 업그레이드 가이드 링크 라벨 등).
+
## [1.0.0-beta.1] - 2026-05-11
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index a3479bb0..e3e813be 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -1838,7 +1838,12 @@
"manual_update_title": "手動アップデート (ZIP 拡張未インストール環境)",
"manual_update_desc": "サーバーに PHP zip 拡張または unzip コマンドがない場合、GitHub からリリース ZIP を直接ダウンロードしてアップデートできます。",
"manual_update_step1": "# 1. GitHub リリースページから ZIP をダウンロード後、サーバーで圧縮解除",
- "manual_update_step2": "# 2. 圧縮解除されたパスを指定してアップデートを実行"
+ "manual_update_step2": "# 2. 圧縮解除されたパスを指定してアップデートを実行",
+ "update_guide_cmd_comment": "# 管理者権限での実行を推奨します (Linux/macOS)",
+ "update_guide_cmd": "$ sudo php artisan core:update",
+ "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
+ "upgrade_guide_link_desc": "運用環境別の権限·ユーザーグループ別の詳細な手順はアップグレードガイドを参照してください。",
+ "upgrade_guide_link_label": "アップグレードガイド全体を表示"
},
"modals": {
"password_confirm_title": "パスワード確認",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
index 66e19198..438f1d9f 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
- "version": "1.0.0-beta.1",
+ "version": "1.0.0-beta.2",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
diff --git a/public/install/api/_guard.php b/public/install/api/_guard.php
index f8ce22a2..d5167e00 100644
--- a/public/install/api/_guard.php
+++ b/public/install/api/_guard.php
@@ -79,3 +79,65 @@ if (!function_exists('installer_guard_or_410')) {
exit;
}
}
+
+if (!function_exists('installer_finalize_is_completed')) {
+ /**
+ * finalize-env.php 전용 멱등 차단 신호.
+ *
+ * 본 함수는 `.env` 의 `INSTALLER_COMPLETED=true` 단독으로만 차단을 판정한다.
+ * `storage/app/g7_installed` 락 파일은 finalize 호출 직전 단계인
+ * `complete_flag` task 가 먼저 생성하므로 차단 사유에서 제외한다.
+ */
+ function installer_finalize_is_completed(): bool
+ {
+ $basePath = installer_resolve_base_path();
+ $envFile = $basePath . '/.env';
+
+ if (!is_file($envFile)) {
+ return false;
+ }
+
+ $env = @parse_ini_file($envFile, false, INI_SCANNER_RAW);
+ if (!is_array($env)) {
+ return false;
+ }
+
+ $flag = strtolower(trim((string) ($env['INSTALLER_COMPLETED'] ?? '')));
+ $flag = trim($flag, "\"'");
+
+ return in_array($flag, ['true', '1', 'yes'], true);
+ }
+}
+
+if (!function_exists('installer_guard_finalize_or_410')) {
+ /**
+ * finalize-env.php 전용 진입 가드.
+ *
+ * 차단 조건: `.env` 의 `INSTALLER_COMPLETED=true` 단독. `g7_installed` 락 파일
+ * 존재는 차단 사유가 아니다 — complete_flag task 가 락 파일을 먼저 만든 직후
+ * 본 엔드포인트가 호출되어 `.env` 머지를 수행해야 하기 때문이다.
+ *
+ * RCE 공격 표면이 없는 finalize 전용으로 한정 — 일반 인스톨러 엔드포인트는
+ * 계속 `installer_guard_or_410()` 을 사용한다.
+ */
+ function installer_guard_finalize_or_410(): void
+ {
+ if (!installer_finalize_is_completed()) {
+ return;
+ }
+
+ if (!headers_sent()) {
+ http_response_code(410);
+ header('Content-Type: application/json; charset=utf-8');
+ header('X-Installer: disabled');
+ header('Cache-Control: no-store');
+ }
+
+ echo json_encode([
+ 'success' => false,
+ 'message' => 'Finalize already completed.',
+ ], JSON_UNESCAPED_UNICODE);
+
+ exit;
+ }
+}
diff --git a/public/install/api/finalize-env.php b/public/install/api/finalize-env.php
index 3f38e089..2c41965e 100644
--- a/public/install/api/finalize-env.php
+++ b/public/install/api/finalize-env.php
@@ -22,7 +22,11 @@ require_once __DIR__ . '/../includes/functions.php';
require_once __DIR__ . '/../includes/installer-runtime.php';
require_once __DIR__ . '/../includes/installer-state.php';
require_once __DIR__ . '/_guard.php';
-installer_guard_or_410();
+// finalize 전용 가드 — `.env` 의 INSTALLER_COMPLETED=true 단독으로만 차단한다.
+// 일반 인스톨러 엔드포인트의 `installer_guard_or_410()` 은 `g7_installed` 락 파일도
+// 차단 사유로 삼는데, 그 락 파일은 finalize 호출 직전 단계의 complete_flag task 가
+// 먼저 생성하므로 자가 차단 회귀가 발생한다 (이슈 #371).
+installer_guard_finalize_or_410();
// ---------------------------------------------------------------------------
// 1. 응답을 즉시 송출하여 브라우저가 완료 UI 를 유지할 수 있게 한다.
@@ -68,7 +72,7 @@ try {
$envBase = generateEnvContent();
if ($envBase === null) {
- error_log('[finalize-env] generateEnvContent() returned null — .env.example missing');
+ addLog('[finalize-env] generateEnvContent() returned null — .env.example missing');
return;
}
@@ -79,25 +83,72 @@ try {
// ServeCommand 의 mtime watcher 가 다중 재시작을 일으키지 않는다.
// atomic rename 대신 file_put_contents 를 사용하여 부모 디렉토리(프로젝트 루트)
// 쓰기 권한 요구를 회피 — 기존 인스톨러 권한 안내 (chmod 664 .env + chgrp) 만으로 충분.
+ //
+ // 권한 정책 (#371): finalize-env.php 는 chmod 0600 시도를 수행하지 않는다.
+ // PHP-FPM(www-data) 가 .env 소유자(예: jjh) 와 다른 자체 구축 환경에서는 POSIX 상
+ // chmod 가 거부되며, 또한 인스톨러가 임의로 0600 으로 깎으면 운영자가 의도해서
+ // 설정한 0664/0640 권한이 손상된다. file_put_contents 는 기존 inode 의 권한을
+ // 변경하지 않으므로, 인스톨러 안내 단계의 권한 (chgrp www-data + chmod 664) 이
+ // 그대로 유지된다. 추가 보안 강화는 Step 6 완료 화면 안내 + INSTALL.md 운영
+ // 가이드에 위임.
if (@file_put_contents($envPath, $envContent, LOCK_EX) === false) {
- error_log('[finalize-env] failed to write .env');
+ $lastError = error_get_last();
+ addLog(sprintf(
+ '[finalize-env] file_put_contents FAILED: path=%s, last_error=%s',
+ $envPath,
+ $lastError['message'] ?? 'unknown',
+ ));
return;
}
- @chmod($envPath, 0600);
+ // 머지 후 권한·소유자·그룹 상태를 명시 기록 — 운영자 사후 검증용
+ $envPermsAfter = fileperms($envPath) & 0777;
+ $envOwnerAfter = @fileowner($envPath);
+ $envGroupAfter = @filegroup($envPath);
+
+ $currentEuid = function_exists('posix_geteuid') ? posix_geteuid() : null;
+ $currentEuser = ($currentEuid !== null && function_exists('posix_getpwuid'))
+ ? (posix_getpwuid($currentEuid)['name'] ?? (string) $currentEuid)
+ : 'unknown';
+
+ addLog(sprintf(
+ '[finalize-env] .env 머지 완료 (chmod 시도 안 함, 기존 권한 보존): path=%s, perms=%s, owner_uid=%s, group_gid=%s, process_euser=%s',
+ $envPath,
+ decoct($envPermsAfter),
+ $envOwnerAfter === false ? 'unknown' : (string) $envOwnerAfter,
+ $envGroupAfter === false ? 'unknown' : (string) $envGroupAfter,
+ $currentEuser,
+ ));
// runtime.php 삭제 — Provider 가 다음 부팅부터 no-op
- deleteInstallerRuntime();
+ if (! deleteInstallerRuntime()) {
+ addLog(sprintf(
+ '[finalize-env] deleteInstallerRuntime FAILED: path=%s, last_error=%s. '
+ .'runtime.php 잔존 — InstallerRuntimeServiceProvider 폴백 동작 유지되나 평문 자격증명 보안 우려.',
+ INSTALLER_RUNTIME_PATH,
+ (error_get_last()['message'] ?? 'unknown'),
+ ));
+ } else {
+ addLog('[finalize-env] runtime.php 삭제 완료');
+ }
// state.json 삭제 — setInstallationCompleteSSE 가 본 단계로 위임함
// (finalize 가 generateEnvContent() 호출 시 state.config 가 필요했기 때문)
if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) {
$stateFilePath = BASE_PATH . '/storage/installer-state.json';
if (is_file($stateFilePath)) {
- @unlink($stateFilePath);
+ if (@unlink($stateFilePath) === false) {
+ addLog(sprintf(
+ '[finalize-env] state.json unlink FAILED: path=%s, last_error=%s',
+ $stateFilePath,
+ (error_get_last()['message'] ?? 'unknown'),
+ ));
+ } else {
+ addLog('[finalize-env] state.json 삭제 완료');
+ }
}
}
} catch (\Throwable $e) {
// 예외 시 runtime.php 보존 → Provider 가 계속 config 주입 → 앱 정상 동작.
- error_log('[finalize-env] unexpected exception: ' . $e->getMessage());
+ addLog('[finalize-env] unexpected exception: ' . $e->getMessage() . ' @ ' . $e->getFile() . ':' . $e->getLine());
}
diff --git a/public/install/includes/installer-state.php b/public/install/includes/installer-state.php
index aa0b023d..20d8a6f0 100644
--- a/public/install/includes/installer-state.php
+++ b/public/install/includes/installer-state.php
@@ -42,7 +42,9 @@ function getInstallationState(): array
// 파일 읽기 권한 체크
if (!is_readable(STATE_PATH)) {
- error_log("State file is not readable: " . STATE_PATH);
+ $msg = "[installer-state] State file is not readable: " . STATE_PATH;
+ error_log($msg);
+ addLog($msg);
return $defaultState;
}
@@ -51,7 +53,9 @@ function getInstallationState(): array
// 파일 읽기 실패 시 기본 상태 반환
if ($content === false) {
- error_log("Failed to read state file: " . STATE_PATH);
+ $msg = "[installer-state] Failed to read state file: " . STATE_PATH;
+ error_log($msg);
+ addLog($msg);
return $defaultState;
}
@@ -61,7 +65,9 @@ function getInstallationState(): array
if (json_last_error() !== JSON_ERROR_NONE) {
$contentLen = strlen($content);
$preview = substr($content, 0, 200);
- error_log("Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview);
+ $msg = "[installer-state] Failed to parse state file JSON (length={$contentLen}): " . json_last_error_msg() . " / preview: " . $preview;
+ error_log($msg);
+ addLog($msg);
return $defaultState;
}
@@ -79,13 +85,17 @@ function saveInstallationState(array $state): bool
// storage 디렉토리 존재 여부 확인 (생성하지 않음)
$storageDir = BASE_PATH . '/storage';
if (!is_dir($storageDir)) {
- error_log("Storage directory does not exist: {$storageDir}");
+ $msg = "[installer-state] Storage directory does not exist: {$storageDir}";
+ error_log($msg);
+ addLog($msg);
return false;
}
// 디렉토리 쓰기 권한 확인
if (!is_writable($storageDir)) {
- error_log("Storage directory is not writable: {$storageDir}");
+ $msg = "[installer-state] Storage directory is not writable: {$storageDir}";
+ error_log($msg);
+ addLog($msg);
return false;
}
@@ -96,7 +106,9 @@ function saveInstallationState(array $state): bool
$content = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($content === false) {
- error_log("Failed to encode state as JSON: " . json_last_error_msg());
+ $msg = "[installer-state] Failed to encode state as JSON: " . json_last_error_msg();
+ error_log($msg);
+ addLog($msg);
return false;
}
@@ -107,7 +119,9 @@ function saveInstallationState(array $state): bool
$result = @file_put_contents($tmpPath, $content, LOCK_EX);
if ($result === false || $result !== strlen($content)) {
- error_log("Failed to write state tmp file: " . $tmpPath);
+ $msg = "[installer-state] Failed to write state tmp file: " . $tmpPath;
+ error_log($msg);
+ addLog($msg);
@unlink($tmpPath);
return false;
}
@@ -115,7 +129,9 @@ function saveInstallationState(array $state): bool
@chmod($tmpPath, 0664);
if (!@rename($tmpPath, STATE_PATH)) {
- error_log("Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH);
+ $msg = "[installer-state] Failed to rename state tmp file: " . $tmpPath . ' → ' . STATE_PATH;
+ error_log($msg);
+ addLog($msg);
@unlink($tmpPath);
return false;
}
@@ -379,13 +395,17 @@ function addLogBatch(array $messages): bool
if (!is_dir($logDir)) {
$created = @mkdir($logDir, 0775, true);
if (!$created) {
- error_log("Failed to create log directory: {$logDir}");
+ $msg = "[addLogBatch] Failed to create log directory: {$logDir}";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단하므로 안전 — error_log fallback 만 수행
return false;
}
}
if (!is_writable($logDir)) {
- error_log("Log directory is not writable: {$logDir}");
+ $msg = "[addLogBatch] Log directory is not writable: {$logDir}";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -412,7 +432,9 @@ function addLogBatch(array $messages): bool
$handle = @fopen($logFile, 'a');
if ($handle === false) {
- error_log("Failed to open log file: {$logFile}");
+ $msg = "[addLogBatch] Failed to open log file: {$logFile}";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단
return false;
}
flock($handle, LOCK_EX);
@@ -425,6 +447,36 @@ function addLogBatch(array $messages): bool
}
function addLog(string $message): bool
+{
+ // 무한 재귀 가드 — addLog 내부 폴백 경로에서 자기 자신을 다시 호출하면 안 된다.
+ // installer-state.php / session.php / finalize-env.php 등에서 error_log 와 addLog 를
+ // 병행 호출하는 경우, addLog 내부의 인프라 실패 폴백 (storage 쓰기 실패 등) 시
+ // 호출자가 다시 addLog 를 부르면 같은 인프라가 다시 실패하여 무한 재귀가 발생할 수 있다.
+ static $inAddLog = false;
+ if ($inAddLog) {
+ // 재진입: installation.log 시도 skip, PHP error_log 만 호출하고 즉시 종료
+ error_log("[addLog reentrant] " . $message);
+ return false;
+ }
+ $inAddLog = true;
+
+ try {
+ return _addLogInternal($message);
+ } finally {
+ $inAddLog = false;
+ }
+}
+
+/**
+ * addLog 의 실제 구현 — 무한 재귀 가드를 거친 후 호출된다.
+ *
+ * 본 함수 안에서는 절대 addLog() 를 호출하지 말 것 (무한 재귀 위험).
+ * 내부 폴백은 error_log() 만 사용.
+ *
+ * @param string $message 기록할 메시지
+ * @return bool 성공 여부
+ */
+function _addLogInternal(string $message): bool
{
$logDir = BASE_PATH . '/storage/logs';
$logFile = $logDir . '/installation.log';
@@ -433,14 +485,18 @@ function addLog(string $message): bool
if (!is_dir($logDir)) {
$created = @mkdir($logDir, 0775, true);
if (!$created) {
- error_log("Failed to create log directory: {$logDir} (storage 권한 확인 필요)");
+ $msg = "[addLog] Failed to create log directory: {$logDir} (storage 권한 확인 필요)";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단 (본 함수는 이미 가드 안)
return false;
}
}
// 로그 디렉토리 쓰기 권한 확인
if (!is_writable($logDir)) {
- error_log("Log directory is not writable: {$logDir}");
+ $msg = "[addLog] Log directory is not writable: {$logDir}";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -471,7 +527,9 @@ function addLog(string $message): bool
// 파일 핸들 열기 (append 모드)
$handle = @fopen($logFile, 'a');
if ($handle === false) {
- error_log("Failed to open log file: {$logFile}");
+ $msg = "[addLog] Failed to open log file: {$logFile}";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단
return false;
}
@@ -492,7 +550,9 @@ function addLog(string $message): bool
clearstatcache(true, $logFile);
if ($result === false) {
- error_log("Failed to write log file: {$logFile}");
+ $msg = "[addLog] Failed to write log file: {$logFile}";
+ error_log($msg);
+ addLog($msg); // 재귀 가드가 차단
return false;
}
diff --git a/public/install/includes/session.php b/public/install/includes/session.php
index b17c432a..37860b14 100644
--- a/public/install/includes/session.php
+++ b/public/install/includes/session.php
@@ -4,8 +4,15 @@
* 세션 관리 및 CSRF 토큰 처리
*
* 그누보드7 웹 인스톨러의 세션 관리와 CSRF 보호 기능을 제공합니다.
+ *
+ * installer-state.php 를 require 하여 addLog() 함수를 사용 가능하게 한다.
+ * index.php 및 일부 API 엔드포인트가 본 파일을 installer-state.php 보다 먼저
+ * require 하기 때문에, 본 파일 안에서 addLog 호출이 필요한 경우를 위해
+ * 명시적으로 의존성을 선언한다 (이슈 #371).
*/
+require_once __DIR__ . '/installer-state.php';
+
// 세션이 이미 시작되지 않았으면 시작
if (session_status() === PHP_SESSION_NONE) {
// 세션 설정
@@ -39,7 +46,9 @@ if (session_status() === PHP_SESSION_NONE) {
if (json_last_error() === JSON_ERROR_NONE && isset($state['g7_locale'])) {
$detectedLang = $state['g7_locale'];
- error_log("[세션 초기화] state.json에서 언어 복원: {$detectedLang}");
+ $msg = "[세션 초기화] state.json에서 언어 복원: {$detectedLang}";
+ error_log($msg);
+ addLog($msg);
}
}
}
@@ -51,15 +60,21 @@ if (session_status() === PHP_SESSION_NONE) {
if ($browserLang !== null) {
$detectedLang = $browserLang;
- error_log("[세션 초기화] 브라우저에서 언어 감지: {$browserLang}");
+ $msg = "[세션 초기화] 브라우저에서 언어 감지: {$browserLang}";
+ error_log($msg);
+ addLog($msg);
}
}
// 3. 최종 기본값
$_SESSION['g7_locale'] = $detectedLang ?? 'ko';
- error_log("[세션 초기화] 최종 설정 언어: {$_SESSION['g7_locale']}");
+ $msg = "[세션 초기화] 최종 설정 언어: {$_SESSION['g7_locale']}";
+ error_log($msg);
+ addLog($msg);
} else {
- error_log("[세션 초기화] g7_locale 이미 존재: {$_SESSION['g7_locale']}");
+ $msg = "[세션 초기화] g7_locale 이미 존재: {$_SESSION['g7_locale']}";
+ error_log($msg);
+ addLog($msg);
}
}
diff --git a/public/install/lang/en.php b/public/install/lang/en.php
index 2dfd6ae5..bb01c6e2 100644
--- a/public/install/lang/en.php
+++ b/public/install/lang/en.php
@@ -138,6 +138,13 @@ return [
'recommendation_env_permission' => 'Set .env file permissions to 644 (chmod 644 .env)',
'recommendation_https' => 'Use HTTPS in production environment',
+ // .env permission hardening hint (Step 5 completion screen, shown when not 0600)
+ 'env_hardening_hint_title' => '.env Permission Hardening (Optional)',
+ 'env_hardening_hint_current_status' => 'Current .env permission is %s (owner: %s). The most secure permission is 0600 (owner read/write only). To apply additional hardening, run the appropriate command below with sudo based on your environment.',
+ 'env_hardening_hint_option_strict' => 'CLI user = web server user (single-user environment)',
+ 'env_hardening_hint_option_group' => 'CLI user ≠ web server user (self-hosted server, etc. — group-readable compromise)',
+ 'env_hardening_hint_note' => 'Verify your environment before running. Applying the wrong permission may prevent the web server from reading .env and cause the site to stop responding.',
+
// Step 5: Complete
'installation_info' => 'Installation Information',
'installation_complete_message' => 'Gnuboard7 has been successfully installed!',
diff --git a/public/install/lang/ko.php b/public/install/lang/ko.php
index b7e34025..6e999121 100644
--- a/public/install/lang/ko.php
+++ b/public/install/lang/ko.php
@@ -138,6 +138,13 @@ return [
'recommendation_env_permission' => '.env 파일 권한을 644로 설정하세요 (chmod 644 .env)',
'recommendation_https' => '프로덕션 환경에서는 반드시 HTTPS를 사용하세요',
+ // .env 권한 보안 강화 안내 (Step 5 완료 화면, 0600 미적용 시 조건부 노출)
+ 'env_hardening_hint_title' => '.env 권한 보안 강화 (선택)',
+ 'env_hardening_hint_current_status' => '현재 .env 권한은 %s 입니다 (소유자: %s). 가장 안전한 권한은 0600 (소유자만 읽기·쓰기) 이며, 다음 두 명령 중 환경에 맞는 쪽을 sudo 로 실행하여 추가 보안 강화를 적용할 수 있습니다.',
+ 'env_hardening_hint_option_strict' => '명령줄 사용자 = 웹 서버 사용자 (단일 사용자 환경)',
+ 'env_hardening_hint_option_group' => '명령줄 사용자 ≠ 웹 서버 사용자 (자체 구축 서버 등 — 그룹 읽기 허용 절충안)',
+ 'env_hardening_hint_note' => '명령 실행 전 환경을 확인하세요. 잘못된 권한 적용 시 웹 서버가 .env 를 읽지 못해 사이트가 응답하지 않을 수 있습니다.',
+
// Step 5: 완료
'installation_info' => '설치 정보',
'installation_complete_message' => '그누보드7이 성공적으로 설치되었습니다!',
diff --git a/public/install/views/5-installation.php b/public/install/views/5-installation.php
index 7ff11cf3..6fce895f 100644
--- a/public/install/views/5-installation.php
+++ b/public/install/views/5-installation.php
@@ -67,6 +67,55 @@ $config = $state['config'] ?? $_SESSION['install_config'] ?? [];
. '' . htmlspecialchars($coreVersion) . ''
. '
';
+ // .env 권한 권장 강화 안내 — 0600 이 아니면 조건부 노출 (#371)
+ // finalize-env.php 는 chmod 시도를 수행하지 않으므로 인스톨러 안내 단계의 권한
+ // (예: 0664 + chgrp www-data) 이 그대로 유지됨. 운영자가 추가로 0600 적용을
+ // 원할 수 있도록 현재 권한이 0600 미만 (그룹/기타 비트가 있음) 이면 명령 예시 노출.
+ //
+ // OS 가드: chmod/chgrp 는 POSIX 권한 모델 (Linux/macOS/BSD) 에서 동작.
+ // Windows 만 명령이 무의미하므로 Windows 만 비노출.
+ $envPath = BASE_PATH . '/.env';
+ if (PHP_OS_FAMILY !== 'Windows' && is_file($envPath)) {
+ $envPerms = fileperms($envPath) & 0777;
+ if ($envPerms !== 0600) {
+ $envOwnerUid = @fileowner($envPath);
+ $envOwnerName = ($envOwnerUid !== false && function_exists('posix_getpwuid'))
+ ? (posix_getpwuid($envOwnerUid)['name'] ?? (string) $envOwnerUid)
+ : 'owner';
+ $webGroupName = function_exists('posix_getegid') && function_exists('posix_getgrgid')
+ ? (posix_getgrgid(posix_getegid())['name'] ?? 'www-data')
+ : 'www-data';
+
+ $hardeningCommandSameOwner = sprintf('chmod 600 %s', htmlspecialchars($envPath));
+ $hardeningCommandDifferentOwner = sprintf(
+ 'sudo chgrp %s %s && sudo chmod 640 %s',
+ htmlspecialchars($webGroupName),
+ htmlspecialchars($envPath),
+ htmlspecialchars($envPath)
+ );
+
+ $completionExtra .= ''
+ . '
' . htmlspecialchars(lang('env_hardening_hint_title')) . '
'
+ . '
'
+ . sprintf(
+ htmlspecialchars(lang('env_hardening_hint_current_status')),
+ decoct($envPerms),
+ htmlspecialchars($envOwnerName)
+ )
+ . '
'
+ . '
'
+ . htmlspecialchars(lang('env_hardening_hint_option_strict'))
+ . '
'
+ . '
' . $hardeningCommandSameOwner . '
'
+ . '
'
+ . htmlspecialchars(lang('env_hardening_hint_option_group'))
+ . '
'
+ . '
' . $hardeningCommandDifferentOwner . '
'
+ . '
' . htmlspecialchars(lang('env_hardening_hint_note')) . '
'
+ . '
';
+ }
+ }
+
echo renderInstallResultSection('completion', 'success', 'installation_completed', 'installation_complete_message', $completionButtons, $completionExtra);
?>
diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index d29d2d1e..d76fc51b 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.0-beta.7] - 2026-05-15
+
+### Changed
+
+- 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 보강합니다. (#30 @yks118 님께서 건의해주셨습니다.)
+- 위 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가하여 코어 `INSTALL.md` 의 "업그레이드" 섹션으로 연결합니다.
+
## [1.0.0-beta.6] - 2026-05-14
### Fixed
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
index 0548f5a7..9e4ae54c 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
@@ -1834,7 +1834,12 @@
"manual_update_title": "Manual Update (No ZIP Extension)",
"manual_update_desc": "If PHP zip extension or unzip command is not available on your server, you can download the release ZIP from GitHub and update manually.",
"manual_update_step1": "# 1. Download ZIP from GitHub releases page and extract on server",
- "manual_update_step2": "# 2. Run update with the extracted path"
+ "manual_update_step2": "# 2. Run update with the extracted path",
+ "update_guide_cmd_comment": "# Run with administrator privileges (Linux/macOS)",
+ "update_guide_cmd": "$ sudo php artisan core:update",
+ "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
+ "upgrade_guide_link_desc": "See the upgrade guide for permission notes and per-version paths.",
+ "upgrade_guide_link_label": "Open the full upgrade guide"
},
"modals": {
"password_confirm_title": "Password Confirmation",
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
index 4074c5c3..deb8c14a 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
@@ -1838,7 +1838,12 @@
"manual_update_title": "수동 업데이트 (ZIP 확장 미설치 환경)",
"manual_update_desc": "서버에 PHP zip 확장이나 unzip 명령어가 없는 경우, GitHub에서 릴리스 ZIP을 직접 다운로드하여 업데이트할 수 있습니다.",
"manual_update_step1": "# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제",
- "manual_update_step2": "# 2. 압축 해제된 경로를 지정하여 업데이트 실행"
+ "manual_update_step2": "# 2. 압축 해제된 경로를 지정하여 업데이트 실행",
+ "update_guide_cmd_comment": "# 관리자 권한으로 실행 권장 (Linux/macOS)",
+ "update_guide_cmd": "$ sudo php artisan core:update",
+ "manual_update_cmd": "$ sudo php artisan core:update --source=/path/to/extracted",
+ "upgrade_guide_link_desc": "운영 환경별 권한/사용자군별 자세한 절차는 업그레이드 가이드를 참고하세요.",
+ "upgrade_guide_link_label": "전체 업그레이드 가이드 보기"
},
"modals": {
"password_confirm_title": "비밀번호 확인",
diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json
index 188e5806..21a3fe81 100644
--- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json
+++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_core_update_guide.json
@@ -38,9 +38,10 @@
{
"type": "basic",
"name": "Div",
- "props": { "className": "bg-gray-900 dark:bg-gray-950 rounded-lg p-4" },
+ "props": { "className": "bg-gray-900 dark:bg-gray-950 rounded-lg p-4 space-y-1" },
"children": [
- { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$ php artisan core:update" }
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.update_guide_cmd_comment" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$t:admin.settings.info.update_guide_cmd" }
]
},
{
@@ -49,6 +50,33 @@
"props": { "className": "text-xs text-gray-500 dark:text-gray-400" },
"text": "$t:admin.settings.info.update_guide_note"
},
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "border-t border-gray-200 dark:border-gray-700 pt-4 mt-2" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "text-sm text-gray-600 dark:text-gray-400 mb-2" },
+ "text": "$t:admin.settings.info.upgrade_guide_link_desc"
+ },
+ {
+ "type": "basic",
+ "name": "A",
+ "props": {
+ "href": "https://github.com/gnuboard/g7/blob/main/INSTALL.md#%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C",
+ "target": "_blank",
+ "rel": "noopener noreferrer",
+ "className": "inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400 hover:underline"
+ },
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:admin.settings.info.upgrade_guide_link_label" },
+ { "type": "basic", "name": "Icon", "props": { "name": "arrow-up-right-from-square", "className": "w-3 h-3" } }
+ ]
+ }
+ ]
+ },
{
"type": "basic",
"name": "Div",
@@ -73,7 +101,7 @@
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.manual_update_step1" },
{ "type": "basic", "name": "P", "props": { "className": "text-xs font-mono text-gray-400" }, "text": "$t:admin.settings.info.manual_update_step2" },
- { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$ php artisan core:update --source=/path/to/extracted" }
+ { "type": "basic", "name": "P", "props": { "className": "text-sm font-mono text-green-400" }, "text": "$t:admin.settings.info.manual_update_cmd" }
]
}
]
diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json
index b0d8d9e5..442cf989 100644
--- a/templates/_bundled/sirsoft-admin_basic/package-lock.json
+++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
- "version": "1.0.0-beta.6",
+ "version": "1.0.0-beta.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
- "version": "1.0.0-beta.6",
+ "version": "1.0.0-beta.7",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json
index 790e5d9a..036a0277 100644
--- a/templates/_bundled/sirsoft-admin_basic/package.json
+++ b/templates/_bundled/sirsoft-admin_basic/package.json
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
- "version": "1.0.0-beta.6",
+ "version": "1.0.0-beta.7",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json
index 0918eb68..4537c685 100644
--- a/templates/_bundled/sirsoft-admin_basic/template.json
+++ b/templates/_bundled/sirsoft-admin_basic/template.json
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
- "version": "1.0.0-beta.6",
+ "version": "1.0.0-beta.7",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
diff --git a/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php b/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php
new file mode 100644
index 00000000..a8d70e5d
--- /dev/null
+++ b/tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php
@@ -0,0 +1,545 @@
+ axis 12 가 putenv / $_ENV / $_SERVER 를
+ * 덮어쓰므로 후속 테스트 / 다음 테스트 파일의 DB 자격증명이 오염되지
+ * 않도록 tearDown 에서 복원할 ENV 키별 원본 스냅샷.
+ */
+ private array $originalEnvSnapshot = [];
+
+ private const ENV_KEYS_FOR_SNAPSHOT = [
+ 'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
+ 'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
+ 'DB_PREFIX', 'APP_KEY',
+ ];
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->runtimePath = base_path('storage/installer/runtime.php');
+ $this->envPath = base_path('.env');
+ $this->envExamplePath = base_path('.env.example');
+ $this->stateJsonPath = base_path('storage/installer-state.json');
+
+ // 실제 운영 .env 와 state.json 보존
+ if (is_file($this->envPath)) {
+ $this->originalEnvExisted = true;
+ $this->originalEnvContent = file_get_contents($this->envPath);
+ }
+ if (is_file($this->stateJsonPath)) {
+ $this->originalStateExisted = true;
+ $this->originalStateContent = file_get_contents($this->stateJsonPath);
+ }
+
+ $this->originalRuntimeDirExisted = is_dir(dirname($this->runtimePath));
+
+ // process ENV 원본 스냅샷 — axis 12 가 putenv() 로 자격증명을 갱신하므로
+ // tearDown 에서 정확히 원본 상태로 복원 (다음 테스트 / 파일 오염 차단)
+ foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) {
+ $value = getenv($key);
+ $this->originalEnvSnapshot[$key] = $value === false ? null : $value;
+ }
+
+ // installer-state.php 등의 DELETE_INSTALLER_AFTER_COMPLETE 상수가 다른 테스트로
+ // 이미 정의되어 있을 수 있다. 본 테스트는 그 상수를 신뢰하지 않고 명시 시나리오마다
+ // 자체 시그널을 사용하므로 추가 정의 불필요.
+
+ // DataMigration 은 autoload 대상이 아님 — 동적 require
+ require_once base_path('upgrades/data/7.0.0-beta.7/migrations/01_FinalizeOrphanedInstallerRuntime.php');
+
+ $this->cleanupArtifacts();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->cleanupArtifacts();
+
+ // .env 복원
+ if ($this->originalEnvExisted && $this->originalEnvContent !== null) {
+ file_put_contents($this->envPath, $this->originalEnvContent);
+ } elseif (! $this->originalEnvExisted && is_file($this->envPath)) {
+ @unlink($this->envPath);
+ }
+
+ // state.json 복원
+ if ($this->originalStateExisted && $this->originalStateContent !== null) {
+ file_put_contents($this->stateJsonPath, $this->originalStateContent);
+ } elseif (! $this->originalStateExisted && is_file($this->stateJsonPath)) {
+ @unlink($this->stateJsonPath);
+ }
+
+ // 본 테스트가 생성한 runtime 디렉토리 정리 (원래 없었을 때만)
+ if (! $this->originalRuntimeDirExisted && is_dir(dirname($this->runtimePath))) {
+ @rmdir(dirname($this->runtimePath));
+ }
+
+ // process ENV 복원 — axis 12 가 putenv() 로 자격증명을 갱신했어도
+ // 다음 테스트 / 파일이 정확히 setUp 직전 상태에서 시작하도록 보장.
+ foreach ($this->originalEnvSnapshot as $key => $value) {
+ if ($value === null) {
+ putenv($key);
+ unset($_ENV[$key], $_SERVER[$key]);
+ } else {
+ putenv($key.'='.$value);
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ }
+ }
+
+ parent::tearDown();
+ }
+
+ private function cleanupArtifacts(): void
+ {
+ if (is_file($this->runtimePath)) {
+ @unlink($this->runtimePath);
+ }
+ }
+
+ private function context(): UpgradeContext
+ {
+ return new UpgradeContext('7.0.0-beta.6', '7.0.0-beta.7', '7.0.0-beta.7');
+ }
+
+ private function writeRuntime(array $data): void
+ {
+ $dir = dirname($this->runtimePath);
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+ $php = "runtimePath, $php);
+ }
+
+ private function sampleRuntime(): array
+ {
+ return [
+ 'db' => [
+ 'write' => [
+ 'host' => '127.0.0.1',
+ 'port' => '3306',
+ 'database' => 'g7_test_db',
+ 'username' => 'test_user',
+ 'password' => 'test_pass_42',
+ ],
+ 'prefix' => 'g7_',
+ ],
+ 'app' => [
+ 'key' => 'base64:'.base64_encode(random_bytes(32)),
+ ],
+ 'created_at' => date('c'),
+ ];
+ }
+
+ private function writeEnv(string $content): void
+ {
+ file_put_contents($this->envPath, $content);
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 1: 정상 finalize — .env 머지 + runtime 삭제 + state.json 삭제
+ // -----------------------------------------------------------------------
+ public function test_finalizes_orphaned_runtime_and_merges_env(): void
+ {
+ $runtime = $this->sampleRuntime();
+ $this->writeRuntime($runtime);
+ $this->writeEnv("APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\nDB_WRITE_PASSWORD=\nAPP_KEY=\n");
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $this->assertFileDoesNotExist($this->runtimePath, 'runtime.php 가 삭제되어야 함');
+ $envContent = file_get_contents($this->envPath);
+ $this->assertStringContainsString('DB_WRITE_DATABASE=g7_test_db', $envContent);
+ $this->assertStringContainsString('DB_WRITE_USERNAME=test_user', $envContent);
+ $this->assertStringContainsString('DB_WRITE_PASSWORD="test_pass_42"', $envContent);
+ $this->assertStringContainsString('APP_KEY=base64:', $envContent);
+ $this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent);
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 2: state.json 부재 시에도 멱등 (.env 머지 + runtime 삭제)
+ // -----------------------------------------------------------------------
+ public function test_finalizes_when_state_json_missing(): void
+ {
+ $this->writeRuntime($this->sampleRuntime());
+ $this->writeEnv("APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\n");
+
+ if (is_file($this->stateJsonPath)) {
+ @unlink($this->stateJsonPath);
+ }
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $this->assertFileDoesNotExist($this->runtimePath);
+ $envContent = file_get_contents($this->envPath);
+ $this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent);
+ // state.json 은 부재 상태 유지 — 비정상 종료 아님
+ $this->assertFileDoesNotExist($this->stateJsonPath);
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 3: runtime.php 부재 → no-op
+ // -----------------------------------------------------------------------
+ public function test_noop_when_runtime_absent(): void
+ {
+ if (is_file($this->runtimePath)) {
+ @unlink($this->runtimePath);
+ }
+ $originalEnv = "APP_ENV=production\nDB_WRITE_HOST=127.0.0.1\n";
+ $this->writeEnv($originalEnv);
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ // .env 가 변경되지 않아야 함 — 멱등 no-op
+ $this->assertSame($originalEnv, file_get_contents($this->envPath));
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 4: .env 의 INSTALLER_COMPLETED=true + runtime 잔존 → 보존 + warn
+ // -----------------------------------------------------------------------
+ public function test_preserves_runtime_when_env_marked_completed(): void
+ {
+ $runtime = $this->sampleRuntime();
+ $this->writeRuntime($runtime);
+ $envContent = "APP_ENV=production\nINSTALLER_COMPLETED=true\n";
+ $this->writeEnv($envContent);
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ // runtime 보존 — 자동 삭제 금지
+ $this->assertFileExists($this->runtimePath);
+ // .env 변경 없음
+ $this->assertSame($envContent, file_get_contents($this->envPath));
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 5: .env 부재 + .env.example 만 존재 → .env.example 기반 신규 생성
+ // -----------------------------------------------------------------------
+ public function test_creates_env_from_example_when_env_absent(): void
+ {
+ if (! is_file($this->envExamplePath)) {
+ $this->markTestSkipped('.env.example 이 프로젝트 루트에 없음 — 테스트 건너뜀');
+ }
+
+ $this->writeRuntime($this->sampleRuntime());
+
+ if (is_file($this->envPath)) {
+ @unlink($this->envPath);
+ }
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $this->assertFileExists($this->envPath, '.env 가 .env.example 기반으로 생성되어야 함');
+ $envContent = file_get_contents($this->envPath);
+ $this->assertStringContainsString('DB_WRITE_DATABASE=g7_test_db', $envContent);
+ $this->assertStringContainsString('INSTALLER_COMPLETED=true', $envContent);
+ $this->assertFileDoesNotExist($this->runtimePath);
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 6: runtime.php 가 배열 아닌 형식 → 보존, skip
+ // -----------------------------------------------------------------------
+ public function test_preserves_runtime_when_format_invalid(): void
+ {
+ $dir = dirname($this->runtimePath);
+ if (! is_dir($dir)) {
+ mkdir($dir, 0755, true);
+ }
+ // 배열을 반환하지 않는 PHP — null 반환
+ file_put_contents($this->runtimePath, "writeEnv($originalEnv);
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $this->assertFileExists($this->runtimePath, '형식 불일치 runtime.php 는 보존되어야 함');
+ $this->assertSame($originalEnv, file_get_contents($this->envPath));
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 7: .env 의 truthy 변형 ("1", quoted "true") 도 보존 트리거
+ // -----------------------------------------------------------------------
+ public function test_preserves_runtime_for_truthy_variants(): void
+ {
+ $runtime = $this->sampleRuntime();
+
+ $variants = [
+ 'INSTALLER_COMPLETED=1',
+ 'INSTALLER_COMPLETED="true"',
+ 'INSTALLER_COMPLETED=yes',
+ ];
+
+ foreach ($variants as $line) {
+ $this->writeRuntime($runtime);
+ $envContent = "APP_ENV=production\n{$line}\n";
+ $this->writeEnv($envContent);
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $this->assertFileExists(
+ $this->runtimePath,
+ sprintf('변형 %s 에서 runtime.php 가 보존되어야 함', $line)
+ );
+ $this->assertSame(
+ $envContent,
+ file_get_contents($this->envPath),
+ sprintf('변형 %s 에서 .env 가 변경되지 않아야 함', $line)
+ );
+
+ // 다음 반복을 위한 정리
+ @unlink($this->runtimePath);
+ }
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 8: 기존 .env 의 권한/그룹 보존 — CLI 사용자와 PHP-FPM 사용자가 다른 환경
+ // (자체 구축 서버: jjh:www-data 0640)
+ // -----------------------------------------------------------------------
+ public function test_preserves_env_mode_when_env_existed(): void
+ {
+ if (DIRECTORY_SEPARATOR === '\\') {
+ $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원 — Linux 환경에서만 검증');
+ }
+
+ $this->writeRuntime($this->sampleRuntime());
+ $this->writeEnv("APP_ENV=production\nAPP_KEY=\n");
+ // 자체 구축 서버 시뮬레이션 — 그룹 읽기 허용 (PHP-FPM 이 그룹 멤버로 읽음)
+ chmod($this->envPath, 0640);
+
+ $beforeMode = fileperms($this->envPath) & 0777;
+ $this->assertSame(0640, $beforeMode, 'precondition: .env 권한 0640');
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $afterMode = fileperms($this->envPath) & 0777;
+ $this->assertSame(
+ $beforeMode,
+ $afterMode,
+ sprintf('기존 .env 권한(%s) 이 머지 후에도 보존되어야 함 — PHP-FPM 이 계속 읽을 수 있도록', decoct($beforeMode))
+ );
+ $this->assertFileDoesNotExist($this->runtimePath);
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 9: 0600 으로 잠긴 .env 도 그대로 보존 — 본 회귀 (#371) 의 원인은 우리가
+ // 0600 으로 강제 다운그레이드 한 것이었음. 운영자가 의도해서 0600 을 둔
+ // 경우 (단일 사용자 환경) 도 같은 정책 — 기존 권한이 SSoT
+ // -----------------------------------------------------------------------
+ public function test_preserves_strict_env_mode_unchanged(): void
+ {
+ if (DIRECTORY_SEPARATOR === '\\') {
+ $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원');
+ }
+
+ $this->writeRuntime($this->sampleRuntime());
+ $this->writeEnv("APP_ENV=production\n");
+ chmod($this->envPath, 0600);
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $this->assertSame(
+ 0600,
+ fileperms($this->envPath) & 0777,
+ '운영자가 의도한 0600 권한도 그대로 보존'
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 10: .env 가 새로 생성될 때 default 권한이 0640 — 0600 으로 강제 금지
+ // (#371 회귀 가드 — PHP-FPM 사용자가 그룹으로만 묶인 환경 보호)
+ // -----------------------------------------------------------------------
+ public function test_new_env_uses_group_readable_default_permission(): void
+ {
+ if (DIRECTORY_SEPARATOR === '\\') {
+ $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원');
+ }
+ if (! is_file($this->envExamplePath)) {
+ $this->markTestSkipped('.env.example 부재');
+ }
+
+ $this->writeRuntime($this->sampleRuntime());
+ if (is_file($this->envPath)) {
+ @unlink($this->envPath);
+ }
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $mode = fileperms($this->envPath) & 0777;
+ $this->assertSame(
+ 0640,
+ $mode,
+ sprintf(
+ '.env 신규 생성 시 default 권한은 0640 이어야 함 (PHP-FPM 그룹 읽기 허용). 실제: %s',
+ decoct($mode)
+ )
+ );
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 11: bootstrap/cache/ 의 그룹을 신규 .env 에 계승 — 자체 구축 서버의
+ // www-data 그룹 자동 식별. 단일 사용자 환경(카페24)에서는 동일 그룹이
+ // 유지되므로 회귀 없음
+ // -----------------------------------------------------------------------
+ public function test_new_env_inherits_group_from_bootstrap_cache(): void
+ {
+ if (DIRECTORY_SEPARATOR === '\\') {
+ $this->markTestSkipped('Windows: POSIX 권한 시멘틱 미지원');
+ }
+ if (! is_file($this->envExamplePath)) {
+ $this->markTestSkipped('.env.example 부재');
+ }
+ $bootstrapCache = base_path('bootstrap/cache');
+ if (! is_dir($bootstrapCache)) {
+ $this->markTestSkipped('bootstrap/cache 디렉토리 부재');
+ }
+
+ $expectedGid = filegroup($bootstrapCache);
+ if ($expectedGid === false) {
+ $this->markTestSkipped('bootstrap/cache filegroup 조회 실패');
+ }
+
+ $this->writeRuntime($this->sampleRuntime());
+ if (is_file($this->envPath)) {
+ @unlink($this->envPath);
+ }
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ $actualGid = filegroup($this->envPath);
+ // chgrp 가 실패할 수 있는 환경(권한 부족)에서는 그룹이 호출 사용자의 default
+ // 그룹으로 떨어질 수 있다. 실패해도 머지 자체는 성공해야 하므로 .env 존재만 단언.
+ $this->assertFileExists($this->envPath);
+
+ // 같은 사용자가 bootstrap/cache 의 그룹 멤버인 경우 chgrp 가 성공해 일치해야 함.
+ // 그렇지 않으면 운영자 수동 보정 안내가 로그에 남는 것까지가 본 axis 의 기대.
+ if ($actualGid !== $expectedGid) {
+ $this->markTestSkipped(sprintf(
+ 'chgrp 실패 환경 (실행자가 그룹 %d 멤버 아님) — silent fallback 으로 통과',
+ $expectedGid
+ ));
+ }
+ $this->assertSame($expectedGid, $actualGid, 'bootstrap/cache 그룹이 .env 에 계승되어야 함');
+ }
+
+ // -----------------------------------------------------------------------
+ // axis 12: 머지 후 부모 프로세스의 process ENV (getenv() / $_ENV / $_SERVER)
+ // 도 신규 자격증명으로 갱신되어야 함.
+ //
+ // 배경 (이슈 #371 후속 회귀):
+ // beta.4~6 손상 환경에서는 .env 가 .env.example 그대로 (DB_WRITE_USERNAME=root,
+ // DB_WRITE_PASSWORD= 등) 이고 자격증명은 runtime.php 에 잔존. 부모 프로세스 부팅
+ // 시점에 Dotenv 가 stale .env 의 ENV (DB_WRITE_USERNAME=root) 를 process ENV
+ // 에 적재. 마이그레이션이 .env 머지 + runtime.php 삭제 를 수행해도 process ENV
+ // 는 그대로. 이후 번들 일괄 업데이트 spawn 자식 (BundledExtensionUpdatePrompt 의
+ // proc_open 5번째 인자 $env = array_merge(getenv(), $_ENV)) 이 부모 stale ENV
+ // 를 그대로 상속받고, Dotenv::createImmutable() 가 이미 채워진 ENV 를 덮어쓰지
+ // 않으므로 자식이 stale root/패스워드YES 로 DB 연결 시도 → Access denied.
+ //
+ // 본 axis 는 마이그레이션 종료 시점에 getenv()/$_ENV/$_SERVER 가 머지된 신규
+ // 자격증명을 반영하는지 검증한다. 한시적 보정 — beta.8 이후 환경에서는 .env 가
+ // 이미 정상이므로 트리거 안 됨.
+ // -----------------------------------------------------------------------
+ public function test_refreshes_process_env_with_merged_credentials(): void
+ {
+ // .env.example 잔존 상태 시뮬레이션 (DB_WRITE_USERNAME=root, DB_WRITE_PASSWORD=)
+ $this->writeEnv(
+ "APP_ENV=production\n"
+ ."DB_WRITE_USERNAME=root\n"
+ ."DB_WRITE_PASSWORD=\n"
+ ."DB_WRITE_DATABASE=laravel\n"
+ ."DB_READ_USERNAME=root\n"
+ ."DB_READ_PASSWORD=\n"
+ ."DB_READ_DATABASE=laravel\n"
+ );
+ // 부모 프로세스 ENV 가 stale .env 값으로 채워진 상태 시뮬레이션
+ putenv('DB_WRITE_USERNAME=root');
+ putenv('DB_WRITE_PASSWORD=');
+ putenv('DB_WRITE_DATABASE=laravel');
+ putenv('DB_READ_USERNAME=root');
+ putenv('DB_READ_PASSWORD=');
+ putenv('DB_READ_DATABASE=laravel');
+ $_ENV['DB_WRITE_USERNAME'] = 'root';
+ $_ENV['DB_WRITE_PASSWORD'] = '';
+ $_ENV['DB_WRITE_DATABASE'] = 'laravel';
+ $_ENV['DB_READ_USERNAME'] = 'root';
+ $_ENV['DB_READ_PASSWORD'] = '';
+ $_ENV['DB_READ_DATABASE'] = 'laravel';
+
+ $this->writeRuntime($this->sampleRuntime());
+
+ (new FinalizeOrphanedInstallerRuntime)->run($this->context());
+
+ // 디스크 .env 머지는 기존 axis 1 에서 검증 — 본 axis 는 process ENV 검증
+ $this->assertSame(
+ 'test_user',
+ getenv('DB_WRITE_USERNAME'),
+ 'getenv(DB_WRITE_USERNAME) 이 runtime 의 신규 자격증명으로 갱신되어야 함'
+ );
+ $this->assertSame(
+ 'test_pass_42',
+ getenv('DB_WRITE_PASSWORD'),
+ 'getenv(DB_WRITE_PASSWORD) 이 runtime 의 신규 비밀번호로 갱신되어야 함'
+ );
+ $this->assertSame(
+ 'g7_test_db',
+ getenv('DB_WRITE_DATABASE'),
+ 'getenv(DB_WRITE_DATABASE) 이 runtime 의 신규 DB 이름으로 갱신되어야 함'
+ );
+ $this->assertSame(
+ 'test_user',
+ $_ENV['DB_WRITE_USERNAME'] ?? null,
+ '$_ENV[DB_WRITE_USERNAME] 이 갱신되어야 함 — proc_open ENV 합집합 전파용'
+ );
+ $this->assertSame(
+ 'test_pass_42',
+ $_ENV['DB_WRITE_PASSWORD'] ?? null,
+ '$_ENV[DB_WRITE_PASSWORD] 도 함께 갱신'
+ );
+ // READ 도 동일 정책 — runtime.read 미지정 시 write 값으로 동기화
+ $this->assertSame('test_user', getenv('DB_READ_USERNAME'));
+ $this->assertSame('test_pass_42', getenv('DB_READ_PASSWORD'));
+ }
+}
diff --git a/tests/Unit/Extension/UpgradeStepsTest.php b/tests/Unit/Extension/UpgradeStepsTest.php
index a9098c51..ee39396b 100644
--- a/tests/Unit/Extension/UpgradeStepsTest.php
+++ b/tests/Unit/Extension/UpgradeStepsTest.php
@@ -71,6 +71,9 @@ class UpgradeStepsTest extends TestCase
'2.0.0' => $step_2_0,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -111,6 +114,9 @@ class UpgradeStepsTest extends TestCase
'1.2.0' => $step_1_2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -139,6 +145,9 @@ class UpgradeStepsTest extends TestCase
'1.1.0' => $callableStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -166,6 +175,9 @@ class UpgradeStepsTest extends TestCase
'1.1.0' => $failingStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -239,6 +251,9 @@ class UpgradeStepsTest extends TestCase
'1.1.0' => $otherStep,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -270,6 +285,9 @@ class UpgradeStepsTest extends TestCase
'1.0.0-beta.2' => $step_beta2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -307,6 +325,9 @@ class UpgradeStepsTest extends TestCase
'1.2.0' => $step_1_2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
@@ -411,6 +432,9 @@ class UpgradeStepsTest extends TestCase
'7.0.0-beta.2' => $step_beta2,
]);
$module->shouldReceive('getIdentifier')->andReturn('test-module');
+ // ExtensionUpgradeGuardHelper::resolveSinceVersion 가 본 두 메서드를 조회 (a9432939f #347)
+ $module->shouldReceive('getRequiredCoreVersion')->andReturn(null);
+ $module->shouldReceive('getVersion')->andReturn('1.0.0');
$manager = $this->createModuleManagerWithMocks();
$method = new \ReflectionMethod($manager, 'runUpgradeSteps');
diff --git a/tests/Unit/Installer/InstallerGuardTest.php b/tests/Unit/Installer/InstallerGuardTest.php
index 996d47f1..3481523f 100644
--- a/tests/Unit/Installer/InstallerGuardTest.php
+++ b/tests/Unit/Installer/InstallerGuardTest.php
@@ -57,8 +57,14 @@ class InstallerGuardTest extends TestCase
private function cleanup(): void
{
- @unlink(BASE_PATH . '/storage/app/g7_installed');
- @unlink(BASE_PATH . '/.env');
+ $lock = BASE_PATH . '/storage/app/g7_installed';
+ if (is_file($lock)) {
+ @unlink($lock);
+ }
+ $env = BASE_PATH . '/.env';
+ if (is_file($env)) {
+ @unlink($env);
+ }
}
public function test_no_signals_means_not_completed(): void
@@ -115,4 +121,65 @@ class InstallerGuardTest extends TestCase
$this->assertTrue(installer_is_completed());
}
+
+ // ------------------------------------------------------------------------
+ // finalize 전용 가드 — installer_finalize_is_completed()
+ //
+ // 일반 가드와 달리 g7_installed 락 파일은 차단 사유에서 제외되어야 한다
+ // (complete_flag task 가 락 파일을 먼저 생성한 직후 finalize 가 호출되는
+ // 설계상의 호출 순서 때문 — 이슈 #371 자가 차단 회귀 가드).
+ // ------------------------------------------------------------------------
+
+ public function test_finalize_guard_passes_with_lock_only(): void
+ {
+ // 정상 1회차 finalize 시나리오: 락 파일은 존재하나 .env 머지 아직 안 됨
+ file_put_contents(BASE_PATH . '/storage/app/g7_installed', '');
+
+ $this->assertFalse(
+ installer_finalize_is_completed(),
+ 'g7_installed 락 파일 단독 존재 시 finalize 차단되면 자가 차단 회귀 (이슈 #371)'
+ );
+ }
+
+ public function test_finalize_guard_passes_with_no_env_file(): void
+ {
+ // .env 자체가 없는 신규 설치 시나리오
+ $this->assertFalse(installer_finalize_is_completed());
+ }
+
+ public function test_finalize_guard_blocks_when_env_completed_true(): void
+ {
+ // 이미 finalize 된 상태 — 멱등 차단
+ file_put_contents(BASE_PATH . '/.env', "APP_ENV=local\nINSTALLER_COMPLETED=true\n");
+
+ $this->assertTrue(installer_finalize_is_completed());
+ }
+
+ public function test_finalize_guard_blocks_with_lock_and_env_completed(): void
+ {
+ // 락 + .env true 동시 존재 → 차단 (멱등)
+ file_put_contents(BASE_PATH . '/storage/app/g7_installed', '');
+ file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=true' . "\n");
+
+ $this->assertTrue(installer_finalize_is_completed());
+ }
+
+ public function test_finalize_guard_passes_when_env_completed_false(): void
+ {
+ file_put_contents(BASE_PATH . '/.env', "INSTALLER_COMPLETED=false\n");
+
+ $this->assertFalse(installer_finalize_is_completed());
+ }
+
+ public function test_finalize_guard_accepts_quoted_and_alt_truthy_values(): void
+ {
+ file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED="true"' . "\n");
+ $this->assertTrue(installer_finalize_is_completed());
+
+ file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=1' . "\n");
+ $this->assertTrue(installer_finalize_is_completed());
+
+ file_put_contents(BASE_PATH . '/.env', 'INSTALLER_COMPLETED=yes' . "\n");
+ $this->assertTrue(installer_finalize_is_completed());
+ }
}
diff --git a/tests/Unit/Installer/InstallerSecurityHardeningTest.php b/tests/Unit/Installer/InstallerSecurityHardeningTest.php
index 6363b822..24787d10 100644
--- a/tests/Unit/Installer/InstallerSecurityHardeningTest.php
+++ b/tests/Unit/Installer/InstallerSecurityHardeningTest.php
@@ -73,9 +73,13 @@ class InstallerSecurityHardeningTest extends TestCase
*/
protected function tearDown(): void
{
- @unlink(STATE_PATH);
+ if (is_file(STATE_PATH)) {
+ @unlink(STATE_PATH);
+ }
$projectStateFile = dirname(__DIR__, 3) . '/storage/installer-state.json';
- @unlink($projectStateFile);
+ if (is_file($projectStateFile)) {
+ @unlink($projectStateFile);
+ }
parent::tearDown();
}
@@ -366,7 +370,7 @@ class InstallerSecurityHardeningTest extends TestCase
'/opt/php/bin/php',
'C:/php/php.exe',
'/nonexistent/path',
- // Windows 절대경로 (백슬래시 포함) — 회귀 가드: PO 환경 (Windows 빌트인 서버)
+ // Windows 절대경로 (백슬래시 포함) — 회귀 가드: Windows 빌트인 서버 환경
'C:\\laragon\\bin\\php\\php-8.3.26-Win32-vs16-x64\\php.exe',
'C:\\php\\php.exe',
'D:\\xampp\\php\\php.exe',
diff --git a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php
index 967916cb..bfc52770 100644
--- a/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php
+++ b/tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php
@@ -20,16 +20,44 @@ class InstallerRuntimeServiceProviderTest extends TestCase
private ?string $originalEnv = null;
+ private string $envPath;
+
+ private ?string $originalEnvContent = null;
+
+ private bool $originalEnvExisted = false;
+
+ /** @var array */
+ private array $originalEnvSnapshot = [];
+
+ private const ENV_KEYS_FOR_SNAPSHOT = [
+ 'DB_WRITE_HOST', 'DB_WRITE_PORT', 'DB_WRITE_DATABASE', 'DB_WRITE_USERNAME', 'DB_WRITE_PASSWORD',
+ 'DB_READ_HOST', 'DB_READ_PORT', 'DB_READ_DATABASE', 'DB_READ_USERNAME', 'DB_READ_PASSWORD',
+ 'DB_PREFIX', 'APP_KEY',
+ ];
+
protected function setUp(): void
{
parent::setUp();
$this->runtimePath = base_path('storage/installer/runtime.php');
+ $this->envPath = base_path('.env');
// 테스트 시작 시 runtime.php 정리 (이전 테스트의 잔재 제거)
if (is_file($this->runtimePath)) {
@unlink($this->runtimePath);
}
+
+ // .env 원본 보존
+ if (is_file($this->envPath)) {
+ $this->originalEnvExisted = true;
+ $this->originalEnvContent = file_get_contents($this->envPath);
+ }
+
+ // process ENV 스냅샷 (stale ENV 보정 테스트가 갱신하므로 다음 테스트 격리)
+ foreach (self::ENV_KEYS_FOR_SNAPSHOT as $key) {
+ $value = getenv($key);
+ $this->originalEnvSnapshot[$key] = $value === false ? null : $value;
+ }
}
protected function tearDown(): void
@@ -49,6 +77,25 @@ class InstallerRuntimeServiceProviderTest extends TestCase
$this->originalEnv = null;
}
+ // .env 복원
+ if ($this->originalEnvExisted && $this->originalEnvContent !== null) {
+ file_put_contents($this->envPath, $this->originalEnvContent);
+ } elseif (! $this->originalEnvExisted && is_file($this->envPath)) {
+ @unlink($this->envPath);
+ }
+
+ // process ENV 복원
+ foreach ($this->originalEnvSnapshot as $key => $value) {
+ if ($value === null) {
+ putenv($key);
+ unset($_ENV[$key], $_SERVER[$key]);
+ } else {
+ putenv($key.'='.$value);
+ $_ENV[$key] = $value;
+ $_SERVER[$key] = $value;
+ }
+ }
+
parent::tearDown();
}
@@ -221,6 +268,161 @@ class InstallerRuntimeServiceProviderTest extends TestCase
$this->assertSame($originalKey, Config::get('app.key'));
}
+ // -----------------------------------------------------------------------
+ // beta.7 한시 보정 — beta.4~6 finalize 결함 환경에서 .env 머지 후 spawn 자식의
+ // stale process ENV (DB_WRITE_USERNAME=root 등) 회귀 차단.
+ //
+ // 트리거 조건 (모두 만족 시에만 보정):
+ // 1. runtime.php 부재 (이미 finalize 완료된 상태)
+ // 2. .env 존재 + 정합 자격증명 보유 (DB_WRITE_USERNAME 이 root 가 아니고 비어있지 않음)
+ // 3. process ENV 의 DB_WRITE_USERNAME 이 'root' 또는 빈 값 (stale 시그니처)
+ // 4. .env 의 INSTALLER_COMPLETED 가 truthy (finalize 성공 신호)
+ //
+ // 위 4개를 모두 만족하면 .env 의 DB_* / APP_KEY 를 process ENV (getenv/$_ENV/$_SERVER)
+ // 에 적용. 정상 환경에서는 #3 (stale 시그니처) 가 false 라 트리거 안 됨.
+ // -----------------------------------------------------------------------
+
+ public function test_recovers_stale_process_env_when_disk_env_is_healthy(): void
+ {
+ $this->withProductionEnv();
+
+ // 디스크 .env 는 정합 자격증명 + INSTALLER_COMPLETED (finalize 완료 신호)
+ file_put_contents($this->envPath, implode("\n", [
+ 'APP_ENV=production',
+ 'DB_WRITE_USERNAME=g7_user',
+ 'DB_WRITE_PASSWORD="real_pw"',
+ 'DB_WRITE_DATABASE=g7',
+ 'DB_READ_USERNAME=g7_user',
+ 'DB_READ_PASSWORD="real_pw"',
+ 'DB_READ_DATABASE=g7',
+ 'INSTALLER_COMPLETED=true',
+ '',
+ ]));
+
+ // process ENV 는 stale (Dotenv 가 부팅 시 .env.example fallback 을 적재한 상태)
+ putenv('DB_WRITE_USERNAME=root');
+ putenv('DB_WRITE_PASSWORD=');
+ putenv('DB_READ_USERNAME=root');
+ putenv('DB_READ_PASSWORD=');
+ $_ENV['DB_WRITE_USERNAME'] = 'root';
+ $_ENV['DB_WRITE_PASSWORD'] = '';
+ $_ENV['DB_READ_USERNAME'] = 'root';
+ $_ENV['DB_READ_PASSWORD'] = '';
+ // $_SERVER 도 stale — bootstrap/app.php 의 Env::disablePutenv() 환경에서는
+ // Dotenv 가 $_ENV/$_SERVER 만 채우므로 셋 다 stale 인 실서버 케이스 재현
+ $_SERVER['DB_WRITE_USERNAME'] = 'root';
+ $_SERVER['DB_WRITE_PASSWORD'] = '';
+ $_SERVER['DB_READ_USERNAME'] = 'root';
+ $_SERVER['DB_READ_PASSWORD'] = '';
+
+ $this->assertFileDoesNotExist($this->runtimePath, 'precondition: runtime.php 부재');
+
+ $provider = new InstallerRuntimeServiceProvider($this->app);
+ $provider->register();
+
+ $this->assertSame('g7_user', getenv('DB_WRITE_USERNAME'), 'stale root 가 .env 의 정합 값으로 갱신되어야 함');
+ $this->assertSame('real_pw', getenv('DB_WRITE_PASSWORD'));
+ $this->assertSame('g7_user', getenv('DB_READ_USERNAME'));
+ $this->assertSame('real_pw', getenv('DB_READ_PASSWORD'));
+ $this->assertSame('g7_user', $_ENV['DB_WRITE_USERNAME'] ?? null);
+ $this->assertSame('g7_user', $_SERVER['DB_WRITE_USERNAME'] ?? null);
+ }
+
+ public function test_no_recovery_when_process_env_already_healthy(): void
+ {
+ $this->withProductionEnv();
+
+ file_put_contents($this->envPath, implode("\n", [
+ 'DB_WRITE_USERNAME=g7_user',
+ 'DB_WRITE_PASSWORD="real_pw"',
+ 'INSTALLER_COMPLETED=true',
+ '',
+ ]));
+
+ // 정상 환경 시뮬레이션 — process ENV 가 이미 정합
+ putenv('DB_WRITE_USERNAME=already_correct');
+ putenv('DB_WRITE_PASSWORD=already_correct_pw');
+ $_ENV['DB_WRITE_USERNAME'] = 'already_correct';
+ $_ENV['DB_WRITE_PASSWORD'] = 'already_correct_pw';
+
+ $provider = new InstallerRuntimeServiceProvider($this->app);
+ $provider->register();
+
+ // 보정 트리거 안 됨 — 정합 ENV 보존 (beta.7 보정이 .env 의 g7_user 로 덮어쓰면 안 됨)
+ $this->assertSame('already_correct', getenv('DB_WRITE_USERNAME'), 'stale 시그니처 아니므로 보정 미발동');
+ $this->assertSame('already_correct_pw', getenv('DB_WRITE_PASSWORD'));
+ }
+
+ public function test_no_recovery_when_disk_env_also_stale(): void
+ {
+ $this->withProductionEnv();
+
+ // 디스크 .env 도 stale (finalize 실패 분기 시뮬레이션)
+ file_put_contents($this->envPath, implode("\n", [
+ 'DB_WRITE_USERNAME=root',
+ 'DB_WRITE_PASSWORD=',
+ 'INSTALLER_COMPLETED=true',
+ '',
+ ]));
+
+ putenv('DB_WRITE_USERNAME=root');
+ putenv('DB_WRITE_PASSWORD=');
+ $_ENV['DB_WRITE_USERNAME'] = 'root';
+ $_ENV['DB_WRITE_PASSWORD'] = '';
+
+ $provider = new InstallerRuntimeServiceProvider($this->app);
+ $provider->register();
+
+ // .env 도 stale 이므로 보정 의미 없음 — 빈 값으로 덮어쓰지 않음
+ $this->assertSame('root', getenv('DB_WRITE_USERNAME'), '.env 도 stale 이면 보정 미발동 (정상 ENV 손상 방지)');
+ }
+
+ public function test_no_recovery_when_installer_not_completed(): void
+ {
+ $this->withProductionEnv();
+
+ // INSTALLER_COMPLETED 부재 — finalize 미수행 (인스톨러 중간 상태) 시그니처
+ file_put_contents($this->envPath, implode("\n", [
+ 'DB_WRITE_USERNAME=g7_user',
+ 'DB_WRITE_PASSWORD="real_pw"',
+ '',
+ ]));
+
+ putenv('DB_WRITE_USERNAME=root');
+ $_ENV['DB_WRITE_USERNAME'] = 'root';
+
+ $provider = new InstallerRuntimeServiceProvider($this->app);
+ $provider->register();
+
+ // INSTALLER_COMPLETED 부재 → 보정 미발동 (인스톨러 진행 중일 수 있음, 보수적 보호)
+ $this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'INSTALLER_COMPLETED 부재 시 보정 미발동');
+ }
+
+ public function test_no_recovery_when_runtime_php_still_present(): void
+ {
+ $this->withProductionEnv();
+
+ // runtime.php 잔존 — 기존 register() 가 Config 주입을 처리하므로 보정 불필요
+ $this->writeRuntime([
+ 'db' => ['write' => ['host' => '127.0.0.1', 'username' => 'rt_user', 'password' => 'rt_pw']],
+ ]);
+
+ file_put_contents($this->envPath, implode("\n", [
+ 'DB_WRITE_USERNAME=g7_user',
+ 'INSTALLER_COMPLETED=true',
+ '',
+ ]));
+
+ putenv('DB_WRITE_USERNAME=root');
+ $_ENV['DB_WRITE_USERNAME'] = 'root';
+
+ $provider = new InstallerRuntimeServiceProvider($this->app);
+ $provider->register();
+
+ // runtime.php 존재 → 기존 경로 (Config 주입) 가 동작. 본 보정은 트리거 안 됨.
+ $this->assertSame('root', getenv('DB_WRITE_USERNAME'), 'runtime.php 잔존 시 본 보정 미발동');
+ }
+
/**
* @param array $data
*/
diff --git a/tests/scenarios/installer-runtime-env-finalize.yaml b/tests/scenarios/installer-runtime-env-finalize.yaml
index 2bb96d0a..640f3004 100644
--- a/tests/scenarios/installer-runtime-env-finalize.yaml
+++ b/tests/scenarios/installer-runtime-env-finalize.yaml
@@ -21,11 +21,24 @@ axes:
env_state: [example_only, partially_filled, fully_finalized]
finalize_call: [success, browser_aborted, retry_after_failure]
rollback_trigger: [none, mid_install, post_finalize]
+ # 이슈 #371 — finalize 가드 자가 차단 회귀 axis
+ g7_installed_lock_state: [absent, present_before_finalize]
+ finalize_guard_signal: [lock_only, env_completed_true_only, lock_and_env_completed_true, neither]
+ orphaned_recovery_path: [none, upgrade_step_auto_finalize, upgrade_step_warn_manual_review]
+ # 이슈 #371 — 웹 서버 사용자/그룹 정합 axis (Upgrade Step 의 권한 조정)
+ cli_vs_phpfpm_user: [same_user, different_user_phpfpm_group_member, different_user_no_group_link]
+ env_preexisting_mode: [absent, 0600_strict, 0640_group_readable, 0664_group_writable]
+ bootstrap_cache_owner_group: [phpfpm_user, root_with_phpfpm_group, missing]
+ # 이슈 #371 — 부모 프로세스 stale ENV 가 spawn 자식에 전파되어
+ # Access denied 가 발생하던 회귀 차단 axis (한시적 보정 — beta.8 이후 트리거 안 됨)
+ parent_process_env_state: [matches_runtime, stale_from_env_example_root, stale_partial]
exclusions:
- { runtime_state: absent, env_state: example_only, finalize_call: success, reason: "runtime 부재 시 finalize 는 멱등적 no-op" }
- { runtime_state: present_corrupted, finalize_call: success, reason: "손상된 runtime 은 머지 미수행, runtime 보존" }
- { rollback_trigger: post_finalize, runtime_state: present_with_db_and_key, reason: "finalize 후 rollback 시점에는 runtime.php 가 이미 삭제된 상태" }
+ - { g7_installed_lock_state: present_before_finalize, finalize_guard_signal: lock_only, finalize_call: success, reason: "이슈 #371 회귀 가드 — 락 단독 시 finalize 통과해야 함 (자가 차단 금지)" }
+ - { orphaned_recovery_path: upgrade_step_warn_manual_review, env_state: fully_finalized, runtime_state: absent, reason: "정상 finalize 완료 환경은 warn 경로 자체가 트리거되지 않음" }
effects:
- install_step5_sse_does_not_modify_env_file
@@ -88,10 +101,41 @@ effects:
- rollback_complete_flag_removes_g7_installed_marker_when_present
- artisan_serve_workers_restart_at_most_once_after_completion_ui_shown
- non_serve_environments_observe_zero_extra_restart
+ # 이슈 #371 — finalize 가드 자가 차단 회귀 차단 + 잔존 환경 자동 복구
+ - finalize_guard_passes_when_only_g7_installed_lock_is_present
+ - finalize_guard_passes_when_env_lacks_installer_completed_flag
+ - finalize_guard_blocks_when_env_has_installer_completed_true_idempotent
+ - finalize_guard_blocks_when_env_has_installer_completed_true_with_quoted_value
+ - finalize_guard_accepts_alternative_truthy_values_1_and_yes
+ - upgrade_step_auto_finalizes_orphaned_runtime_when_env_not_marked_completed
+ - upgrade_step_skips_when_runtime_php_is_absent_idempotent_noop
+ - upgrade_step_warns_when_env_marked_completed_but_runtime_php_still_exists_no_auto_delete
+ - upgrade_step_falls_back_to_env_example_when_env_file_is_absent
+ - upgrade_step_preserves_runtime_php_when_env_write_fails_safe_fallback
+ - upgrade_step_logs_skip_when_runtime_php_returns_non_array_format_mismatch
+ - upgrade_step_deletes_state_json_after_merge_when_DELETE_INSTALLER_AFTER_COMPLETE_set
+ - upgrade_step_preserves_state_json_when_DELETE_INSTALLER_AFTER_COMPLETE_unset
+ # 이슈 #371 — 웹 서버 권한 자동 조정
+ - upgrade_step_preserves_existing_env_mode_uid_gid_when_env_existed_before_merge
+ - upgrade_step_preserves_strict_0600_env_mode_when_owner_intent_present
+ - upgrade_step_uses_0640_default_permission_for_newly_created_env_not_0600
+ - upgrade_step_detects_web_server_group_from_bootstrap_cache_for_new_env
+ - upgrade_step_falls_back_to_storage_logs_when_bootstrap_cache_absent
+ - upgrade_step_skips_chown_when_lacking_root_privilege_silent_with_log_hint
+ - upgrade_step_skips_uid_when_bootstrap_cache_owner_is_root_to_avoid_root_owned_env
+ - upgrade_step_inherits_group_only_when_bootstrap_cache_owner_is_root_but_group_is_not
+ # 이슈 #371 — 부모 프로세스 process ENV 도 머지된 자격증명으로 갱신하여 spawn 자식 전파 보장
+ - upgrade_step_refreshes_parent_process_env_with_merged_db_write_credentials
+ - upgrade_step_refreshes_parent_process_env_with_merged_db_read_credentials
+ - upgrade_step_skips_env_refresh_when_runtime_credential_keys_are_missing
+ - upgrade_step_does_not_overwrite_app_key_env_when_runtime_value_lacks_base64_prefix
test_files:
- tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php
- tests/Unit/Installer/InstallerRuntimeHelperTest.php
+ - tests/Unit/Installer/InstallerGuardTest.php
+ - tests/Feature/Console/Core/FinalizeOrphanedInstallerRuntimeTest.php
+ - tests/Feature/Upgrade/Beta7FinalizeOrphanedInstallerRuntimeTest.php
manual_verification:
- scenario: A_artisan_serve
diff --git a/tests/scenarios/installer-session-cookie-probe.yaml b/tests/scenarios/installer-session-cookie-probe.yaml
index a08a6ac9..e302b6a9 100644
--- a/tests/scenarios/installer-session-cookie-probe.yaml
+++ b/tests/scenarios/installer-session-cookie-probe.yaml
@@ -9,7 +9,7 @@ description: |
비표준 포트 + 일부 브라우저 정책 조합)을 사전 감지하여 운영자에게 명시
안내한다.
- 본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (PO 결정: 경고만).
+ 본 진단으로는 "설치하기" 버튼 자체를 차단하지 않는다 (정책: 경고만).
검증 시점은 Step 0 1회 한정. 또한 `session.cookie_samesite` 기본값을
Strict → Lax 로 완화하여 차단 케이스 자체를 줄인다 — 진단은 잔여 케이스
대비 안내 역할.
diff --git a/upgrades/Upgrade_7_0_0_beta_7.php b/upgrades/Upgrade_7_0_0_beta_7.php
new file mode 100644
index 00000000..47243b41
--- /dev/null
+++ b/upgrades/Upgrade_7_0_0_beta_7.php
@@ -0,0 +1,30 @@
+runInternal($context);
+ } catch (\Throwable $e) {
+ $context->logger->warning(sprintf(
+ '[7.0.0-beta.7] FinalizeOrphanedInstallerRuntime 실패 (runtime.php 보존, 계속 진행): %s',
+ $e->getMessage(),
+ ));
+ Log::warning('beta.7 FinalizeOrphanedInstallerRuntime 실패', [
+ 'error' => $e->getMessage(),
+ 'trace' => $e->getTraceAsString(),
+ ]);
+ }
+ }
+
+ private function runInternal(UpgradeContext $context): void
+ {
+ $basePath = base_path();
+ $runtimePath = $basePath.DIRECTORY_SEPARATOR.self::RUNTIME_RELATIVE;
+ $envPath = $basePath.DIRECTORY_SEPARATOR.'.env';
+ $envExamplePath = $basePath.DIRECTORY_SEPARATOR.'.env.example';
+
+ if (! is_file($runtimePath)) {
+ $context->logger->info('[7.0.0-beta.7] runtime.php 부재 — 정상 finalize 완료 상태로 판정, skip');
+
+ return;
+ }
+
+ if ($this->envInstallerCompletedIsTrue($envPath)) {
+ $context->logger->warning(sprintf(
+ '[7.0.0-beta.7] .env 의 INSTALLER_COMPLETED=true 이지만 %s 가 잔존. '
+ .'평문 자격증명 보안 우려 — 수동 검토 후 삭제 권장',
+ self::RUNTIME_RELATIVE,
+ ));
+
+ return;
+ }
+
+ $runtime = $this->readRuntime($runtimePath);
+ if ($runtime === null) {
+ $context->logger->info('[7.0.0-beta.7] runtime.php 형식 불일치 (배열 아님) — runtime.php 보존, skip');
+
+ return;
+ }
+
+ $envBase = $this->loadEnvBase($envPath, $envExamplePath);
+ if ($envBase === null) {
+ $context->logger->error('[7.0.0-beta.7] .env / .env.example 모두 읽기 실패 — runtime.php 보존');
+
+ return;
+ }
+
+ // 머지 *전에* .env 의 기존 권한/소유자/그룹 스냅샷을 떠둔다 — 머지 후 그대로 복원해
+ // CLI 실행 사용자(jjh/root) 와 PHP-FPM 사용자(www-data) 가 다른 환경에서도
+ // PHP-FPM 이 계속 .env 를 읽을 수 있도록 한다.
+ $envExisted = is_file($envPath);
+ $preservedStat = $envExisted ? $this->snapshotFileStat($envPath) : null;
+
+ $merged = $this->mergeRuntimeIntoEnv($envBase, $runtime);
+
+ if (@file_put_contents($envPath, $merged, LOCK_EX) === false) {
+ $context->logger->error('[7.0.0-beta.7] .env 쓰기 실패 — runtime.php 보존');
+
+ return;
+ }
+
+ $this->applyEnvPermissions($envPath, $preservedStat, $basePath, $context);
+
+ // 부모 프로세스의 process ENV 도 머지된 자격증명으로 갱신 (이슈 #371 후속 회귀 차단)
+ //
+ // 배경: beta.4~6 손상 환경에서는 .env 가 .env.example 그대로 (DB_WRITE_USERNAME=root,
+ // DB_WRITE_PASSWORD= 등) 이고 자격증명은 runtime.php 에 잔존. 부모 프로세스 부팅
+ // 시점에 Dotenv 가 stale .env 의 값을 process ENV (getenv() / $_ENV / $_SERVER)
+ // 에 적재한 상태이며, Config 메모리만 InstallerRuntimeServiceProvider 가 정상값으로
+ // 주입해두었기 때문에 부모 자체는 정상 동작한다.
+ //
+ // 이 시점에 본 마이그레이션이 .env 머지 + runtime.php 삭제를 수행해도 부모의 process
+ // ENV 는 stale 한 채 유지된다. 그 직후 BundledExtensionUpdatePrompt 의 번들 일괄
+ // 업데이트가 proc_open 으로 spawn 자식을 띄울 때 5번째 인자에 array_merge(getenv(),
+ // $_ENV) 를 전달하므로 자식이 stale 값(root / 빈 비밀번호)을 그대로 상속받는다.
+ // 자식의 Laravel 부팅 시 Dotenv::createImmutable() 은 이미 채워진 ENV 를 덮어쓰지
+ // 않으므로 자식이 머지된 .env 의 정상 값을 보지 못하고 root@localhost (using
+ // password: YES — 환경에 따라 다른 stale 비밀번호) 로 DB 연결 시도 → Access denied.
+ //
+ // beta.8 이후 정상 .env 환경에서는 부팅 시점부터 process ENV 가 올바르므로 본
+ // 보정 로직이 트리거되어도 동일 값으로 재기록되는 멱등 동작이며 부작용이 없다.
+ $this->refreshProcessEnvFromRuntime($runtime);
+
+ @unlink($runtimePath);
+
+ if (defined('DELETE_INSTALLER_AFTER_COMPLETE') && DELETE_INSTALLER_AFTER_COMPLETE) {
+ $stateFilePath = $basePath.DIRECTORY_SEPARATOR.self::STATE_JSON_RELATIVE;
+ if (is_file($stateFilePath)) {
+ @unlink($stateFilePath);
+ }
+ }
+
+ // 본 PR 부수효과 정리 — beta.7 업그레이드 도중 자식 spawn 프로세스 (root 권한)
+ // 가 부팅하면서 InstallerRuntimeServiceProvider 의 recover 가 발화 → Config
+ // 보정 → CoreServiceProvider DB 가드 통과 → loadModules() → CachesModuleStatus
+ // 의 캐시 쓰기 흐름이 진행되어, storage/framework/cache/data// 디렉토리가
+ // root:root 권한으로 생성됨. 이후 일반 PHP-FPM 워커(www-data 그룹)가 그
+ // 디렉토리에 캐시 파일 쓰기 시도 시 Permission denied → 사이트 500 회귀.
+ //
+ // 정상 운영 디렉토리 (storage/, bootstrap/cache 등) 의 소유자/그룹을 기준으로
+ // storage/framework/cache/data/ 하위의 root 소유 항목만 일괄 chown.
+ $this->normalizeCachePermissions($basePath, $context);
+
+ $context->logger->info(
+ '[7.0.0-beta.7] 잔존 runtime.php 자동 finalize 완료 — .env 머지 + runtime.php 삭제 + state.json 정리'
+ );
+ }
+
+ /**
+ * beta.7 업그레이드 부수효과 보정 — storage/framework/cache/data/ 하위의 root 소유
+ * 디렉토리/파일을 정상 소유자로 chown.
+ *
+ * 정상 소유자/그룹 결정:
+ * - 1순위: storage/framework/cache/data/ 자신의 owner/group (이미 정상값을 보유)
+ * - 2순위: bootstrap/cache/ 의 owner/group
+ * - 3순위: 그 외 — skip (안전한 fallback 없으면 강제 변경 안 함)
+ *
+ * @param string $basePath 프로젝트 base path
+ */
+ private function normalizeCachePermissions(string $basePath, UpgradeContext $context): void
+ {
+ if (! function_exists('chown') || ! function_exists('posix_geteuid')) {
+ return;
+ }
+
+ $cacheRoot = $basePath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'framework'.DIRECTORY_SEPARATOR.'cache'.DIRECTORY_SEPARATOR.'data';
+ if (! is_dir($cacheRoot)) {
+ return;
+ }
+
+ // 정상 owner/group 추정
+ [$targetUid, $targetGid] = $this->inferCacheOwnership($basePath, $cacheRoot);
+ if ($targetUid === null || $targetGid === null) {
+ $context->logger->info('[7.0.0-beta.7] 캐시 권한 보정 skip — 정상 owner/group 추정 실패');
+
+ return;
+ }
+
+ $changed = 0;
+ $failed = 0;
+ $iter = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($cacheRoot, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($iter as $entry) {
+ /** @var \SplFileInfo $entry */
+ $path = $entry->getPathname();
+ $currentUid = @fileowner($path);
+ $currentGid = @filegroup($path);
+ // 이미 정상이면 skip
+ if ($currentUid === $targetUid && $currentGid === $targetGid) {
+ continue;
+ }
+ // root (uid=0) 소유 항목만 보정 — 사용자가 의도해서 만든 다른 소유자는 보존
+ if ($currentUid !== 0 && $currentGid !== 0) {
+ continue;
+ }
+ $okOwner = @chown($path, $targetUid);
+ $okGroup = @chgrp($path, $targetGid);
+ if ($okOwner && $okGroup) {
+ $changed++;
+ } else {
+ $failed++;
+ }
+ }
+
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] 캐시 권한 보정 완료 — root 소유 항목 changed=%d failed=%d (uid=%d gid=%d)',
+ $changed,
+ $failed,
+ $targetUid,
+ $targetGid,
+ ));
+ }
+
+ /**
+ * 캐시 디렉토리의 정상 owner/group 추정.
+ *
+ * @return array{0:int|null, 1:int|null}
+ */
+ private function inferCacheOwnership(string $basePath, string $cacheRoot): array
+ {
+ // 1순위: cache/data 자신의 owner/group (chown 대상 자식이 root 라도 부모는 정상값일 가능성)
+ $uid = @fileowner($cacheRoot);
+ $gid = @filegroup($cacheRoot);
+ if (is_int($uid) && $uid !== 0 && is_int($gid)) {
+ return [$uid, $gid];
+ }
+
+ // 2순위: bootstrap/cache
+ $bootstrapCache = $basePath.DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'cache';
+ if (is_dir($bootstrapCache)) {
+ $uid = @fileowner($bootstrapCache);
+ $gid = @filegroup($bootstrapCache);
+ if (is_int($uid) && $uid !== 0 && is_int($gid)) {
+ return [$uid, $gid];
+ }
+ }
+
+ // 3순위 fallback 없음
+ return [null, null];
+ }
+
+ /**
+ * `.env` 의 INSTALLER_COMPLETED 가 truthy 값인지 판정.
+ *
+ * `_guard.php` 의 installer_finalize_is_completed() 와 동일 정책.
+ */
+ private function envInstallerCompletedIsTrue(string $envPath): bool
+ {
+ if (! is_file($envPath)) {
+ return false;
+ }
+ $parsed = @parse_ini_file($envPath, false, INI_SCANNER_RAW);
+ if (! is_array($parsed)) {
+ return false;
+ }
+ $flag = strtolower(trim((string) ($parsed['INSTALLER_COMPLETED'] ?? '')));
+ $flag = trim($flag, "\"'");
+
+ return in_array($flag, ['true', '1', 'yes'], true);
+ }
+
+ /**
+ * runtime.php 를 읽어 배열 반환. 부재/형식 불일치 시 null.
+ *
+ * installer-runtime.php 의 readInstallerRuntime() 와 동일 로직 — 본 클래스
+ * 안에 중복 구현하여 BASE_PATH 상수 의존 회피 (V-1 안전 격리).
+ */
+ private function readRuntime(string $runtimePath): ?array
+ {
+ if (! is_file($runtimePath)) {
+ return null;
+ }
+ $data = @include $runtimePath;
+
+ return is_array($data) ? $data : null;
+ }
+
+ /**
+ * `.env` 가 존재하면 그 내용을, 아니면 `.env.example` 을 base 로 반환.
+ *
+ * 둘 다 없으면 null.
+ */
+ private function loadEnvBase(string $envPath, string $envExamplePath): ?string
+ {
+ if (is_file($envPath)) {
+ $content = @file_get_contents($envPath);
+
+ return $content === false ? null : $content;
+ }
+ if (is_file($envExamplePath)) {
+ $content = @file_get_contents($envExamplePath);
+
+ return $content === false ? null : $content;
+ }
+
+ return null;
+ }
+
+ /**
+ * runtime 배열을 .env 본문에 머지.
+ *
+ * installer-runtime.php 의 mergeRuntimeIntoEnv() 와 동일 결과 — escapeEnvValue
+ * / replaceEnvLine 을 로컬 private 메서드로 인라인 구현.
+ */
+ private function mergeRuntimeIntoEnv(string $envContent, array $runtime): string
+ {
+ $write = $runtime['db']['write'] ?? null;
+ if (is_array($write)) {
+ $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_HOST', (string) ($write['host'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_PORT', (string) ($write['port'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_DATABASE', (string) ($write['database'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_USERNAME', (string) ($write['username'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_WRITE_PASSWORD', $this->escapeEnvValue((string) ($write['password'] ?? '')));
+ }
+
+ $read = $runtime['db']['read'] ?? $write;
+ if (is_array($read)) {
+ $envContent = $this->replaceEnvLine($envContent, 'DB_READ_HOST', (string) ($read['host'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_READ_PORT', (string) ($read['port'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_READ_DATABASE', (string) ($read['database'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_READ_USERNAME', (string) ($read['username'] ?? ''));
+ $envContent = $this->replaceEnvLine($envContent, 'DB_READ_PASSWORD', $this->escapeEnvValue((string) ($read['password'] ?? '')));
+ }
+
+ if (isset($runtime['db']['prefix'])) {
+ $envContent = $this->replaceEnvLine($envContent, 'DB_PREFIX', (string) $runtime['db']['prefix']);
+ }
+
+ $appKey = $runtime['app']['key'] ?? null;
+ if (is_string($appKey) && str_starts_with($appKey, 'base64:')) {
+ $envContent = $this->replaceEnvLine($envContent, 'APP_KEY', $appKey);
+ }
+
+ if (! preg_match('/^INSTALLER_COMPLETED=/m', $envContent)) {
+ $envContent = rtrim($envContent)."\n\n# Installation Status\nINSTALLER_COMPLETED=true\n";
+ }
+
+ return $envContent;
+ }
+
+ /**
+ * 부모 프로세스의 process ENV 를 runtime 자격증명으로 갱신.
+ *
+ * getenv() / $_ENV / $_SERVER 3곳 모두 갱신 — proc_open 의 ENV 합집합 전파와
+ * Dotenv::createImmutable() 의 덮어쓰기 차단 정책 모두에 대응.
+ *
+ * 트리거 가드는 호출자에서 이미 보장됨:
+ * - runtime.php 부재 (beta.3 정상 환경 포함) → runInternal 진입 직후 return
+ * - .env 의 INSTALLER_COMPLETED=true (이미 finalize 완료) → 별도 return
+ * - 따라서 본 메서드 도달 = beta.4~6 손상 환경의 머지 성공 분기
+ *
+ * 본 메서드 자체도 빈 값 가드: runtime 의 username/password 키가 부재하거나
+ * 빈 문자열이면 그 키에 대한 ENV 갱신을 skip 하여 정상 ENV 를 빈 값으로
+ * 덮어쓰는 회귀를 차단.
+ *
+ * @param array $runtime
+ */
+ private function refreshProcessEnvFromRuntime(array $runtime): void
+ {
+ $write = $runtime['db']['write'] ?? null;
+ if (! is_array($write)) {
+ return;
+ }
+ $read = $runtime['db']['read'] ?? $write;
+ if (! is_array($read)) {
+ $read = $write;
+ }
+
+ $pairs = [
+ 'DB_WRITE_HOST' => $write['host'] ?? null,
+ 'DB_WRITE_PORT' => $write['port'] ?? null,
+ 'DB_WRITE_DATABASE' => $write['database'] ?? null,
+ 'DB_WRITE_USERNAME' => $write['username'] ?? null,
+ 'DB_WRITE_PASSWORD' => $write['password'] ?? null,
+ 'DB_READ_HOST' => $read['host'] ?? null,
+ 'DB_READ_PORT' => $read['port'] ?? null,
+ 'DB_READ_DATABASE' => $read['database'] ?? null,
+ 'DB_READ_USERNAME' => $read['username'] ?? null,
+ 'DB_READ_PASSWORD' => $read['password'] ?? null,
+ ];
+
+ if (isset($runtime['db']['prefix'])) {
+ $pairs['DB_PREFIX'] = (string) $runtime['db']['prefix'];
+ }
+ $appKey = $runtime['app']['key'] ?? null;
+ if (is_string($appKey) && str_starts_with($appKey, 'base64:')) {
+ $pairs['APP_KEY'] = $appKey;
+ }
+
+ foreach ($pairs as $key => $value) {
+ // null / 빈 문자열은 skip — 정상 ENV 를 빈 값으로 덮어쓰는 회귀 차단
+ // (단, 비밀번호는 의도적으로 빈 문자열일 수 있으므로 password 만 예외 허용)
+ if ($value === null) {
+ continue;
+ }
+ $stringValue = (string) $value;
+ if ($stringValue === '' && ! in_array($key, ['DB_WRITE_PASSWORD', 'DB_READ_PASSWORD', 'DB_PREFIX'], true)) {
+ continue;
+ }
+
+ putenv($key.'='.$stringValue);
+ $_ENV[$key] = $stringValue;
+ $_SERVER[$key] = $stringValue;
+ }
+ }
+
+ private function escapeEnvValue(string $value): string
+ {
+ if ($value !== '') {
+ $value = str_replace(["\r", "\n"], '', $value);
+ }
+ if ($value === '') {
+ return '""';
+ }
+ $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value);
+
+ return '"'.$escaped.'"';
+ }
+
+ private 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;
+ }
+
+ // ---------------------------------------------------------------------
+ // 권한·소유자·그룹 자동 조정 — 코어/인스톨러 헬퍼 비의존 (V-1 안전 격리)
+ // ---------------------------------------------------------------------
+
+ /**
+ * 파일의 권한/소유자/그룹을 스냅샷.
+ *
+ * @return array{mode:int|null, uid:int|null, gid:int|null}|null 파일 부재/stat 실패 시 null
+ */
+ private function snapshotFileStat(string $path): ?array
+ {
+ if (! is_file($path)) {
+ return null;
+ }
+ $perms = @fileperms($path);
+ $uid = @fileowner($path);
+ $gid = @filegroup($path);
+
+ if ($perms === false && $uid === false && $gid === false) {
+ return null;
+ }
+
+ return [
+ 'mode' => $perms === false ? null : ($perms & 0777),
+ 'uid' => $uid === false ? null : $uid,
+ 'gid' => $gid === false ? null : $gid,
+ ];
+ }
+
+ /**
+ * 머지된 .env 에 적절한 권한·소유자·그룹을 적용.
+ *
+ * 정책:
+ * 1. 기존 .env 가 있었으면 스냅샷 그대로 복원 — 운영자가 의도해서 설정한 권한 보존
+ * (PHP-FPM 사용자가 그룹으로 묶여 있는 자체 구축 환경 / 단일 사용자 카페24 환경
+ * 모두 자동 보존)
+ * 2. 신규 생성된 경우 → 웹 서버 컨텍스트 (소유자/그룹) 를 추정하여 적용
+ * - 추정: bootstrap/cache/ 의 소유자/그룹 (PHP-FPM 이 쓰는 영역)
+ * - 권한: 0640 (소유자 rw + 그룹 r) — 비밀번호 노출 차단 + 웹 서버 그룹 읽기 허용
+ *
+ * 모든 chown/chgrp/chmod 호출은 실패해도 진행 (운영자가 수동 보정 가능하도록 로그 안내).
+ *
+ * @param array{mode:int|null, uid:int|null, gid:int|null}|null $preservedStat
+ * 머지 직전 .env 가 존재했다면 그 stat. 부재였으면 null.
+ */
+ private function applyEnvPermissions(
+ string $envPath,
+ ?array $preservedStat,
+ string $basePath,
+ UpgradeContext $context,
+ ): void {
+ if ($preservedStat !== null) {
+ // 기존 .env 의 권한/소유자/그룹 복원
+ $this->restoreFileStat($envPath, $preservedStat, $context);
+
+ return;
+ }
+
+ // 신규 생성 .env — 웹 서버 컨텍스트 추정 + 안전한 기본 권한 적용
+ $webContext = $this->detectWebServerContext($basePath);
+ $processUser = $this->describeProcessUser();
+
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] .env 신규 생성 시작 — 프로세스 사용자=%s, 웹 서버 컨텍스트 추정: uid=%s, gid=%s',
+ $processUser,
+ $webContext['uid'] !== null ? (string) $webContext['uid'] : 'unknown',
+ $webContext['gid'] !== null ? (string) $webContext['gid'] : 'unknown',
+ ));
+
+ if ($webContext['uid'] !== null) {
+ $this->tryChown($envPath, $webContext['uid'], $context);
+ } else {
+ $context->logger->info('[7.0.0-beta.7] chown 시도 skip — 웹 서버 사용자 추정 실패 (bootstrap/cache, storage/logs 모두 신호 없음)');
+ }
+ if ($webContext['gid'] !== null) {
+ $this->tryChgrp($envPath, $webContext['gid'], $context);
+ } else {
+ $context->logger->info('[7.0.0-beta.7] chgrp 시도 skip — 웹 서버 그룹 추정 실패');
+ }
+
+ $chmodResult = @chmod($envPath, self::DEFAULT_ENV_PERMISSIONS);
+ $actualMode = fileperms($envPath) & 0777;
+
+ if ($chmodResult === false) {
+ $lastError = error_get_last();
+ $context->logger->warning(sprintf(
+ '[7.0.0-beta.7] chmod(.env, %s) FAILED — 실제 권한: %s, 프로세스=%s, last_error=%s. '
+ .'운영자 수동 보정: chmod %s %s',
+ decoct(self::DEFAULT_ENV_PERMISSIONS),
+ decoct($actualMode),
+ $processUser,
+ $lastError['message'] ?? 'unknown',
+ decoct(self::DEFAULT_ENV_PERMISSIONS),
+ $envPath,
+ ));
+ } else {
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] chmod(.env, %s) 성공 — 실제 권한: %s, 프로세스=%s',
+ decoct(self::DEFAULT_ENV_PERMISSIONS),
+ decoct($actualMode),
+ $processUser,
+ ));
+ }
+
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] .env 신규 생성 완료 — 최종 mode=%s, owner_uid=%s, group_gid=%s',
+ decoct($actualMode),
+ (string) (@fileowner($envPath) ?: 'unknown'),
+ (string) (@filegroup($envPath) ?: 'unknown'),
+ ));
+ }
+
+ /**
+ * 스냅샷 stat 을 파일에 그대로 복원.
+ *
+ * 우선순위: chmod → chown → chgrp. chown 은 root 권한 필요할 수 있어 실패 흔함 — 실패해도 진행.
+ */
+ private function restoreFileStat(string $path, array $stat, UpgradeContext $context): void
+ {
+ $processUser = $this->describeProcessUser();
+
+ if ($stat['mode'] !== null) {
+ $chmodResult = @chmod($path, $stat['mode']);
+ $actualMode = fileperms($path) & 0777;
+ if ($chmodResult === false) {
+ $lastError = error_get_last();
+ $context->logger->warning(sprintf(
+ '[7.0.0-beta.7] chmod(%s, %s) FAILED — 실제 권한: %s, 프로세스=%s, last_error=%s',
+ basename($path),
+ decoct($stat['mode']),
+ decoct($actualMode),
+ $processUser,
+ $lastError['message'] ?? 'unknown',
+ ));
+ }
+ }
+ if ($stat['uid'] !== null) {
+ $this->tryChown($path, $stat['uid'], $context);
+ }
+ if ($stat['gid'] !== null) {
+ $this->tryChgrp($path, $stat['gid'], $context);
+ }
+
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] .env 기존 권한 보존 시도 — 요청 mode=%s, uid=%s, gid=%s / 실제 mode=%s, uid=%s, gid=%s, 프로세스=%s',
+ $stat['mode'] !== null ? decoct($stat['mode']) : 'unchanged',
+ $stat['uid'] !== null ? (string) $stat['uid'] : 'unchanged',
+ $stat['gid'] !== null ? (string) $stat['gid'] : 'unchanged',
+ decoct(fileperms($path) & 0777),
+ (string) (@fileowner($path) ?: 'unknown'),
+ (string) (@filegroup($path) ?: 'unknown'),
+ $processUser,
+ ));
+ }
+
+ /**
+ * 현재 프로세스 사용자 ("name(uid)") 를 문자열로 반환 — 로그 가독성용.
+ */
+ private function describeProcessUser(): string
+ {
+ if (! function_exists('posix_geteuid')) {
+ return 'unknown(no-posix)';
+ }
+ $euid = posix_geteuid();
+ $name = function_exists('posix_getpwuid') ? (posix_getpwuid($euid)['name'] ?? null) : null;
+
+ return ($name ?? 'uid') . '(' . $euid . ')';
+ }
+
+ /**
+ * 웹 서버 컨텍스트 (PHP-FPM/Apache 가 쓰는 사용자/그룹) 를 디스크 신호로 추정.
+ *
+ * `getWebServerUser()` 같은 인스톨러 헬퍼 (현재 PHP 프로세스 = web user 가정) 는
+ * CLI 컨텍스트에서 동작하지 않으므로 사용 금지. 대신 PHP-FPM 이 실제로 *쓴 파일*
+ * 의 소유자/그룹을 신호로 사용.
+ *
+ * 우선순위:
+ * 1. bootstrap/cache/*.php (services.php / packages.php) — Laravel 부팅 시 PHP-FPM
+ * 이 직접 쓰는 파일. 가장 신뢰도 높음
+ * 2. bootstrap/cache/ 디렉토리 자체
+ * 3. storage/logs/ 디렉토리 (PHP-FPM 의 또 다른 쓰기 영역)
+ * 4. 모두 실패 시 null — 호출자가 chown/chgrp skip
+ *
+ * @return array{uid:int|null, gid:int|null}
+ */
+ private function detectWebServerContext(string $basePath): array
+ {
+ $candidates = [
+ $basePath.DIRECTORY_SEPARATOR.'bootstrap/cache/services.php',
+ $basePath.DIRECTORY_SEPARATOR.'bootstrap/cache/packages.php',
+ $basePath.DIRECTORY_SEPARATOR.'bootstrap/cache',
+ $basePath.DIRECTORY_SEPARATOR.'storage/logs',
+ ];
+
+ foreach ($candidates as $candidate) {
+ if (! file_exists($candidate)) {
+ continue;
+ }
+ $uid = @fileowner($candidate);
+ $gid = @filegroup($candidate);
+ if ($uid === false && $gid === false) {
+ continue;
+ }
+
+ // root (uid=0) 단독 신호는 회피 — sudo 로 캐시가 생성된 환경일 수 있음.
+ // 그룹은 root 라도 사용 가능 (그룹이 web user group 인 경우 흔함).
+ if ($uid === 0 && $gid !== false && $gid !== 0) {
+ return ['uid' => null, 'gid' => $gid];
+ }
+
+ return [
+ 'uid' => $uid === false ? null : $uid,
+ 'gid' => $gid === false ? null : $gid,
+ ];
+ }
+
+ return ['uid' => null, 'gid' => null];
+ }
+
+ /**
+ * chown 시도. 시도/성공/실패 모두 로그에 명시.
+ */
+ private function tryChown(string $path, int $uid, UpgradeContext $context): void
+ {
+ if (! function_exists('chown')) {
+ $context->logger->info('[7.0.0-beta.7] chown skip — chown() 함수 사용 불가 (PHP 빌드 옵션)');
+
+ return;
+ }
+ $beforeUid = @fileowner($path);
+ $result = @chown($path, $uid);
+ $afterUid = @fileowner($path);
+
+ if ($result === false) {
+ $lastError = error_get_last();
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] chown(%s, uid=%d) FAILED — before=%s, after=%s, 프로세스=%s, last_error=%s. '
+ .'원인 추정: 비-root 사용자는 다른 사용자로 chown 불가. 운영자 수동 보정: sudo chown %d %s',
+ basename($path),
+ $uid,
+ $beforeUid === false ? 'unknown' : (string) $beforeUid,
+ $afterUid === false ? 'unknown' : (string) $afterUid,
+ $this->describeProcessUser(),
+ $lastError['message'] ?? 'unknown',
+ $uid,
+ $path,
+ ));
+ } else {
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] chown(%s, uid=%d) 성공 — before=%s → after=%s',
+ basename($path),
+ $uid,
+ $beforeUid === false ? 'unknown' : (string) $beforeUid,
+ $afterUid === false ? 'unknown' : (string) $afterUid,
+ ));
+ }
+ }
+
+ /**
+ * chgrp 시도. 시도/성공/실패 모두 로그에 명시.
+ */
+ private function tryChgrp(string $path, int $gid, UpgradeContext $context): void
+ {
+ if (! function_exists('chgrp')) {
+ $context->logger->info('[7.0.0-beta.7] chgrp skip — chgrp() 함수 사용 불가');
+
+ return;
+ }
+ $beforeGid = @filegroup($path);
+ $result = @chgrp($path, $gid);
+ $afterGid = @filegroup($path);
+
+ if ($result === false) {
+ $lastError = error_get_last();
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] chgrp(%s, gid=%d) FAILED — before=%s, after=%s, 프로세스=%s, last_error=%s. '
+ .'운영자 수동 보정: sudo chgrp %d %s',
+ basename($path),
+ $gid,
+ $beforeGid === false ? 'unknown' : (string) $beforeGid,
+ $afterGid === false ? 'unknown' : (string) $afterGid,
+ $this->describeProcessUser(),
+ $lastError['message'] ?? 'unknown',
+ $gid,
+ $path,
+ ));
+ } else {
+ $context->logger->info(sprintf(
+ '[7.0.0-beta.7] chgrp(%s, gid=%d) 성공 — before=%s → after=%s',
+ basename($path),
+ $gid,
+ $beforeGid === false ? 'unknown' : (string) $beforeGid,
+ $afterGid === false ? 'unknown' : (string) $afterGid,
+ ));
+ }
+ }
+}