diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a4202f..9ffecb7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ ### Fixed - 검색엔진 노출용으로 만들어 내보내는 페이지에 실제로 존재하지 않는 스타일 파일 주소(`/build/assets/app.css`)가 포함되어, 브라우저 콘솔에 404 오류가 남던 문제를 수정했습니다. 이 주소는 화면 표시에 쓰이지 않는 파일을 가리키던 불필요한 참조였으며, 검색엔진 노출용 페이지의 스타일은 템플릿이 제공하는 파일이 그대로 담당하므로 화면에는 영향이 없었습니다. 이제 이 참조를 제거해 오류가 발생하지 않습니다. (#64 @bigmsg 님께서 제보해주셨습니다.) +- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.) ## [7.0.4] - 2026-07-14 diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php index 3012dc67..b15986f7 100644 --- a/app/Console/Commands/Core/CoreUpdateCommand.php +++ b/app/Console/Commands/Core/CoreUpdateCommand.php @@ -8,6 +8,7 @@ use App\Exceptions\UpgradeHandoffException; use App\Extension\CoreVersionChecker; use App\Extension\Helpers\CoreBackupHelper; use App\Extension\Helpers\FilePermissionHelper; +use App\Extension\Helpers\StorageLinkHelper; use App\Extension\ModuleManager; use App\Extension\PluginManager; use App\Extension\TemplateManager; @@ -517,6 +518,12 @@ class CoreUpdateCommand extends Command $this->surfacePermissionWarnings($service, $log); $log('업데이트 경로 소유권 복원 완료'); + // 소유권 복원 완료 후 · 유지보수 해제 전에 `public/storage` symlink 를 방어 복구한다. + // `--prune` 이 릴리즈 소스에 없는 symlink 를 orphan 으로 삭제했거나(#43) 손상된 + // 경우에도, 첫 요청부터 `/storage/...` 가 200 을 반환하도록 링크를 재생성한다. + StorageLinkHelper::ensurePublicStorageLink(); + $log('public/storage symlink 확인/복구 완료'); + $service->cleanupPending($pendingPath); if ($backupPath) { @@ -623,6 +630,11 @@ class CoreUpdateCommand extends Command $this->surfacePermissionWarnings($service, $log); $log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)'); + // 핸드오프 시에도 파일은 이미 toVersion 으로 반영돼 `public/storage` symlink 가 + // 삭제/손상됐을 수 있으므로 유지보수 해제 전에 동일하게 방어 복구한다(#43). + StorageLinkHelper::ensurePublicStorageLink(); + $log('public/storage symlink 확인/복구 완료 (핸드오프)'); + if (! empty($pendingPath)) { $service->cleanupPending($pendingPath); } diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php index 35e99155..b6881b3a 100644 --- a/app/Extension/Helpers/FilePermissionHelper.php +++ b/app/Extension/Helpers/FilePermissionHelper.php @@ -33,12 +33,17 @@ class FilePermissionHelper * @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부 * @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부 * @param array|null $applyList 적용 대상 파일 상대경로 화이트리스트 - * (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면 - * 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어 - * 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink - * 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님). + * (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면 + * 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어 + * 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink + * 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님). + * @param array $preserveLinkPaths orphan 정리 시 삭제하지 않고 보존할 + * symlink/junction 의 상대경로 화이트리스트(이 디렉토리 루트 기준, 예 `['storage']`). + * `removeOrphans=true` 라도 이 목록에 매칭되는 orphan 링크는 삭제하지 않는다 — 코어 + * 업데이트 prune 모드에서 `public/storage` symlink 가 orphan 으로 판정되어 삭제되던 + * 결함(#43) 차단. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다. */ - public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null): void + public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false, bool $preserveTopLevelOrphans = false, ?array $applyList = null, array $preserveLinkPaths = []): void { if (! File::isDirectory($destination)) { // 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속 @@ -81,7 +86,10 @@ class FilePermissionHelper if ($item->isDir()) { // preserveTopLevelOrphans 는 최상위 한 레벨 한정 — 자식 재귀에는 항상 false 전달. - static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList); + // preserveLinkPaths 는 이 디렉토리 루트 기준 상대경로(예 `public/storage` → `storage`)라 + // 재귀에 그대로 하향 전달 — removeOrphanItems 가 각 레벨에서 itemRelativePath 전체로 + // 매칭하므로 중첩 보호 경로도 정확히 판정된다. + static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans, preserveTopLevelOrphans: false, applyList: $applyList, preserveLinkPaths: $preserveLinkPaths); } else { // 증분 모드(applyList 지정): 화이트리스트에 없는 파일은 스킵 — // 코어가 변경하지 않은 파일의 현재 디스크 상태(사용자 수정 포함)를 보존. @@ -94,7 +102,7 @@ class FilePermissionHelper // 소스에 없는 대상 파일/디렉토리 삭제 if ($removeOrphans && File::isDirectory($destination)) { - static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans); + static::removeOrphanItems($source, $destination, $excludes, $relativePath, $preserveTopLevelOrphans, $preserveLinkPaths); } } @@ -202,8 +210,11 @@ class FilePermissionHelper * @param array $excludes 제외할 이름 또는 경로 목록 * @param string $relativePath 현재 상대 경로 * @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부 + * @param array $preserveLinkPaths 삭제하지 않고 보존할 symlink/junction + * 상대경로 화이트리스트(루트 기준). orphan 이 링크이면서 이 목록에 매칭되면 삭제하지 + * 않고 보존한다 — `public/storage` symlink 보호(#43). 화이트리스트 밖 orphan 링크는 삭제. */ - protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false): void + protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath, bool $preserveTopLevelOrphans = false, array $preserveLinkPaths = []): void { // 최상위 한 레벨에서 소스에 없는 항목(사용자 추가) 보존 — `_bundled/my-project` 등. // 호출자(copyDirectory)는 최상위 진입 시에만 relativePath='' + 플래그 true 로 들어오며, @@ -227,6 +238,21 @@ class FilePermissionHelper // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { + // 보호 화이트리스트 매칭 링크/junction 은 orphan 이어도 보존 — 코어 업데이트 + // prune 모드에서 `public/storage` symlink 가 릴리즈 소스에 없다는 이유로 삭제되어 + // 업로드 파일이 404 되던 결함(#43) 차단. 화이트리스트 밖 orphan 링크는 아래에서 삭제. + if ( + in_array($itemRelativePath, $preserveLinkPaths, true) + && (is_link($destItem->getPathname()) || static::isReparsePoint($destItem->getPathname())) + ) { + Log::info('removeOrphanItems: 보호 대상 orphan 링크 보존', [ + 'path' => $destItem->getPathname(), + 'relative' => $itemRelativePath, + ]); + + continue; + } + // symlink / Windows junction 은 링크 자체만 제거 — File::deleteDirectory 는 is_dir() // 추적 검사 후 재귀 삭제하므로 link-to-dir 인 경우 target 의 모든 파일을 삭제하는 // 사고 발생 가능. 링크 검사가 isDir() 보다 먼저 평가되어야 한다. diff --git a/app/Extension/Helpers/StorageLinkHelper.php b/app/Extension/Helpers/StorageLinkHelper.php new file mode 100644 index 00000000..d8e2cc05 --- /dev/null +++ b/app/Extension/Helpers/StorageLinkHelper.php @@ -0,0 +1,172 @@ +logger`(채널 + * `upgrade`), 코어 업데이트 커맨드는 `Log::channel('upgrade')`. 파사드를 직접 쓰면 + * migration 의 채널 격리가 깨지므로 로거를 주입받고, `null` 이면 `upgrade` 채널로 폴백한다. + */ +class StorageLinkHelper +{ + /** + * `public/storage` 가 정상 symlink 인지 확인하고, 아니면 멱등 재생성합니다. + * + * 멱등 판정 순서: + * 1. `is_link(public/storage)` → 이미 정상 symlink, no-op. + * 2. `storage/app/public` 부재 → Laravel storage:link 컨벤션 미사용 환경, skip + * (false positive 차단 — 운영자 의도적 구성 존중). + * 3. `public/storage` 부재 → `symlink()` 로 신규 생성 (prune 삭제 갭 복구 핵심). + * 4. `public/storage` 가 일반 디렉토리 → `.broken.{YmdHis}` rename 백업 후 재생성 + * (dereferenced 콘텐츠 데이터 손실 없이 보존). + * + * Windows `SeCreateSymbolicLink` 권한 부족으로 `symlink()` 가 실패하면 target 이 + * 디렉토리일 때 `mklink /J`(junction) 로 폴백한다. 그래도 실패하면 rename 백업을 + * 원위치로 복원하고 warning 로그만 남긴다 (graceful degrade — 데이터 손실 없음). + * + * @param LoggerInterface|null $logger 로그 채널 격리용 주입 로거. + * `null` 이면 `Log::channel('upgrade')` 로 폴백. + */ + public static function ensurePublicStorageLink(?LoggerInterface $logger = null): void + { + $logger ??= Log::channel('upgrade'); + + $publicStorage = public_path('storage'); + $storageSource = storage_path('app/public'); + + // 1) 이미 정상 symlink → no-op (멱등) + if (is_link($publicStorage)) { + return; + } + + // 2) Laravel storage:link source 부재 → 표준 미사용 환경, skip (false positive 차단) + if (! is_dir($storageSource)) { + return; + } + + // 3) public/storage 부재 → 신규 생성 (prune 이 삭제한 케이스 — 기존 스텝 미커버) + if (! file_exists($publicStorage)) { + if (static::createLink($storageSource, $publicStorage)) { + $logger->info('[storage-link] public/storage symlink 신규 생성', [ + 'target' => $storageSource, + ]); + } else { + $logger->warning('[storage-link] public/storage symlink 생성 실패 — 수동 `php artisan storage:link` 필요', [ + 'target' => $storageSource, + ]); + } + + return; + } + + // Windows junction(`mklink /J`)은 `is_link()` 가 false 지만 이미 정상 링크다 + // (reparse point). rename 백업 대상에서 제외 — no-op 로 존중한다. + if (static::isReparsePoint($publicStorage)) { + return; + } + + // 4) public/storage 가 일반 디렉토리(손상) → rename 백업 후 재생성 + $backup = $publicStorage.'.broken.'.date('YmdHis'); + if (! @rename($publicStorage, $backup)) { + $logger->warning('[storage-link] public/storage rename 실패 — 복구 skip', [ + 'path' => $publicStorage, + ]); + + return; + } + + if (static::createLink($storageSource, $publicStorage)) { + $logger->info('[storage-link] public/storage symlink 재생성 완료 — 백업 디렉토리 검증 후 수동 삭제 권장', [ + 'backup' => $backup, + 'target' => $storageSource, + ]); + + return; + } + + // 재생성 실패 → rename 원복 (데이터 손실 없음) + @rename($backup, $publicStorage); + $logger->warning('[storage-link] public/storage symlink 재생성 실패 — rename 원복, 수동 `php artisan storage:link` 필요', [ + 'target' => $storageSource, + ]); + } + + /** + * `symlink()` 로 링크를 생성하고, 실패 시 Windows junction 폴백을 시도합니다. + * + * Windows 에서 PHP `symlink()` 는 `SeCreateSymbolicLink` 권한이 필요하지만 junction + * (`mklink /J`)은 권한 없이 생성 가능하므로, target 이 디렉토리이면 junction 으로 폴백한다. + * + * @param string $target 링크가 가리킬 대상 (`storage/app/public`) + * @param string $link 생성할 링크 경로 (`public/storage`) + * @return bool 링크 생성 성공 여부 + */ + protected static function createLink(string $target, string $link): bool + { + if (@symlink($target, $link)) { + return true; + } + + // symlink 실패 + target 이 디렉토리 → Windows junction 폴백. + if (PHP_OS_FAMILY === 'Windows' && is_dir($target)) { + exec( + 'cmd /c mklink /J '.escapeshellarg($link).' '.escapeshellarg($target).' 2>&1', + $out, + $code + ); + if ($code === 0 && is_dir($link)) { + return true; + } + } + + return false; + } + + /** + * 경로가 Windows JUNCTION 인지 판정합니다 (`is_link()` 미인식 reparse point). + * + * Windows 에서 junction 과 일반 디렉토리는 둘 다 `is_dir()` 이 true 라 구분되지 않고, + * `readlink()` 는 일반 디렉토리에는 **자기 경로**를, junction 에는 **target 경로**를 + * 반환한다. 따라서 "readlink 가 자기 경로와 다른 target 을 반환" 하는 경우만 junction 으로 + * 판정한다. 일반 symlink 는 호출부(case 1)가 먼저 처리하므로 여기서 false. 비-Windows 는 + * 항상 false. 이 판정으로 정상 junction 은 case 4(rename 재생성) 대상에서 제외된다. + * + * @param string $path 검사할 경로 + * @return bool junction 여부 + */ + protected static function isReparsePoint(string $path): bool + { + if (PHP_OS_FAMILY !== 'Windows') { + return false; + } + + // 일반 symlink / 파일 은 junction 아님. + if (is_link($path) || is_file($path)) { + return false; + } + + $target = @readlink($path); + if ($target === false) { + return false; + } + + // readlink 가 자기 경로와 다른 target 을 반환하면 junction. (일반 디렉토리는 자기 경로 + // 반환 → 같으므로 false.) 경로 구분자/대소문자 차이를 흡수하기 위해 정규화 후 비교. + $normalize = static fn (string $p): string => strtolower(str_replace('/', '\\', rtrim($p, '/\\'))); + + return $normalize($target) !== $normalize($path); + } +} diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php index 10cd302d..a7bcf6fc 100644 --- a/app/Services/CoreUpdateService.php +++ b/app/Services/CoreUpdateService.php @@ -904,7 +904,7 @@ class CoreUpdateService * @param \Closure|null $onProgress 진행 콜백 * @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용 * @param array|null $applyList 적용 대상 상대경로 목록(target 접두사 포함). - * `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기. + * `CoreBackupHelper::computeApplyList()` 산출물. null 이면 증분 불가로 판단해 전체 덮어쓰기. */ public function applyUpdate(string $sourcePath, ?\Closure $onProgress = null, bool $prune = false, ?array $applyList = null): void { @@ -953,6 +953,12 @@ class CoreUpdateService // 소스(번들 확장만 포함)에 없다는 이유로 삭제되던 결함 차단. // 번들 확장 디렉토리 *내부* stale 정리는 prune 모드에서만 수행된다. $preserveTopLevelOrphans = str_ends_with($normalizedTarget, '_bundled'); + + // `public` 타깃 prune 시 `public/storage` symlink 를 orphan 삭제에서 보호한다 — + // 릴리즈 소스에는 런타임 symlink 가 없으므로 orphan 으로 판정되어 삭제되면 + // 업로드 파일이 404 되는 결함(#43). 다른 타깃은 빈 배열이라 영향 없음. + $preserveLinkPaths = $normalizedTarget === 'public' ? ['storage'] : []; + FilePermissionHelper::copyDirectory( $src, $dest, @@ -961,6 +967,7 @@ class CoreUpdateService removeOrphans: $prune, preserveTopLevelOrphans: $preserveTopLevelOrphans, applyList: $targetApplyList, + preserveLinkPaths: $preserveLinkPaths, ); } else { // 단일 파일 target — 증분 모드에서는 적용 목록에 있을 때만 복사. diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 0debc90d..44e29a07 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -210,6 +210,8 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): --prune 모드 (또는 백업 부재 fallback): - targets 전체 무조건 덮어쓰기 + orphan 삭제 (기존 동작) - 백업 부재 시 base 가 없어 3-way 불가 → 안전하게 전체 덮어쓰기로 회귀 + 안내 출력 +- `public/storage` symlink 는 orphan 삭제에서 보호됨 — `public` 타깃 정리 시 + `preserveLinkPaths: ['storage']` 화이트리스트로 링크/junction 을 보존 (#43, 아래 §참조) 공통: - 자동 발견 폴백: targets 에 미등재된 source 최상위 항목도 스캔하여 적용 @@ -222,6 +224,11 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): > **증분 모드 잔존 stale 파일 정리**: 기본(증분) 모드는 orphan 을 삭제하지 않으므로, 신 버전에서 제거된 파일이 활성 디렉토리에 잔존할 수 있다. 완료 요약이 잔존을 안내하며, 정리하려면 다음 업데이트를 `--prune` 으로 실행하거나 단발성 정리 도구 `php artisan hotfix:rollback-stale-files --prune` 을 사용한다 (진단 모드 기본, `--prune` 시 확인 프롬프트 후 정리, symlink/protected_paths 가드 적용). 상세 사용법: [docs/cheatsheet.md](../cheatsheet.md) "단발성 결함 보정 (hotfix)". +> **`public/storage` symlink 보존 + 종료 시 복구 (#43)**: 심층 방어 2층 구조로 `--prune` 실행 후에도 `public/storage` symlink 가 정상 유지된다. +> - **층 1 (예방)**: `--prune` 은 `public` 타깃에서 orphan(릴리즈 소스에 없는 항목)을 삭제하는데, 런타임 symlink 인 `public/storage` 는 릴리즈 소스에 없어 orphan 으로 판정되어 삭제되던 결함이 있었다(업로드 파일 404). `applyUpdate` 가 `public` 타깃 처리 시 `FilePermissionHelper::copyDirectory(..., preserveLinkPaths: ['storage'])` 로 화이트리스트를 전달해, 매칭되는 orphan symlink/junction 만 삭제에서 제외한다. 화이트리스트 밖 orphan 링크는 기존대로 삭제된다(정밀 보호 — 무조건 보존 아님). +> - **층 2 (복구)**: 업데이트 종료 시점(정상 Step 11 + 핸드오프 catch)에 `StorageLinkHelper::ensurePublicStorageLink()` 를 호출해 `public/storage` 가 정상 링크인지 확인하고, 부재/손상이면 `storage/app/public` 을 가리키는 링크를 (필요 시 `.broken.{YmdHis}` rename 백업 후) 재생성한다. 버전 무관 매 업데이트 실행. Windows `SeCreateSymbolicLink` 권한 부족 시 junction(`mklink /J`) 폴백까지 시도하고, 그래도 실패하면 rename 원복 + 수동 `storage:link` 안내(데이터 손실 없음). 롤백 catch 경로는 백업 복원이 링크를 원상 회복하므로 대상 아님. +> - 이 복구 로직은 beta.5 DataMigration `RecoverPublicStorageSymlink` 와 `StorageLinkHelper` 로 일원화되어 있다(부재→재생성 케이스까지 상위호환). + > **자동 발견 폴백의 배경 (engine-v / beta.4 이후)**: Step 7 은 부모 프로세스의 `config('app.update.targets')` 를 사용한다. 부모는 업그레이드 *직전* 의 코드/메모리 상태이므로 신버전이 도입한 신규 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 가 부모의 stale targets 에서 누락된다. 폴백은 이 결함을 안전망으로 차단하며, `config/app.php` 의 `update.protected_paths` 가 런타임 데이터(`storage`)·로컬 환경(`.env*`)·별도 파이프라인 산출물(`vendor`)·개발 메타(`.git`/`.claude`/`.serena` 등) 의 의도치 않은 덮어쓰기를 방지한다. ### Step 9: 마이그레이션 + 동기화 @@ -671,7 +678,8 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') | 메서드 | 시그니처 | 설명 | |--------|---------|------| -| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제 | +| `applyUpdate()` | `(string $sourcePath, ?Closure $onProgress, bool $prune = false, ?array $applyList = null): void` | _pending → base_path 적용. `$applyList` 지정 + `!$prune` 이면 증분(코어 변경분만), 그 외 전체 덮어쓰기 + orphan 삭제. `public` 타깃은 `copyDirectory(..., preserveLinkPaths: ['storage'])` 로 `public/storage` symlink/junction 을 orphan 삭제에서 보호 (#43) | +| `StorageLinkHelper::ensurePublicStorageLink()` | `(?\Psr\Log\LoggerInterface $logger = null): void` | `public/storage` 멱등 복구. 정상 링크면 no-op, 부재/손상이면 `storage/app/public` 링크 재생성(부재 시 신규, 손상 디렉토리는 `.broken.{YmdHis}` 백업 후). Windows junction 폴백. `CoreUpdateCommand` 종료 시점 + migration 05 가 호출 (#43) | | `runComposerInstallInPending()` | `(string $pendingPath, ?Closure $onProgress): void` | _pending에서 composer install (--no-scripts) | | `isComposerUnchangedForCore()` | `(string $pendingPath): bool` | composer.json/lock MD5 비교 | | `runComposerInstall()` | `(?Closure $onProgress): void` | base_path에서 composer install | diff --git a/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php b/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php index 5705181c..078889f2 100644 --- a/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php +++ b/tests/Feature/Upgrades/Upgrade_7_0_0_beta_5RecoveryTest.php @@ -434,18 +434,44 @@ class Upgrade_7_0_0_beta_5RecoveryTest extends TestCase } /** - * public/storage 자체가 부재 (storage:link 미실행 환경) → skip. + * public/storage 자체가 부재 (prune 삭제 또는 storage:link 미실행) → 재생성 (#43). + * + * `StorageLinkHelper` 위임 이후 동작이 상위호환으로 확장됐다 — 기존 스텝은 "부재" 를 + * skip 했지만, 헬퍼는 Laravel storage:link source 가 존재하면 symlink 를 신규 생성한다. + * 이는 `--prune` 이 orphan 으로 삭제한 갭을 복구하는 핵심 케이스다. */ - public function test_recover_public_storage_symlink_skips_when_public_storage_absent(): void + public function test_recover_public_storage_symlink_recreates_when_public_storage_absent(): void { $fakeBase = $this->arrangeFakeBase(); File::ensureDirectoryExists($fakeBase.'/public'); File::ensureDirectoryExists($fakeBase.'/storage/app/public'); - // public/storage 의도적 미생성 — storage:link 가 한 번도 실행되지 않은 환경 + // public/storage 의도적 미생성 — prune 이 삭제했거나 storage:link 미실행 환경 + + // symlink 생성 권한 확인 (Windows 일반 사용자면 skip) + if (! @symlink($fakeBase.'/storage/app/public', $fakeBase.'/symlink_probe')) { + $this->markTestSkipped('symlink 생성 권한 부족 (Windows SeCreateSymbolicLink)'); + } + @unlink($fakeBase.'/symlink_probe'); $this->invokeRecoverPublicStorageSymlink(); - $this->assertFalse(is_link($fakeBase.'/public/storage'), 'public/storage 부재 시 자동 생성 안 함'); + $this->assertTrue(is_link($fakeBase.'/public/storage'), 'source 존재 시 부재 public/storage 를 symlink 로 재생성해야 한다 (#43)'); + $this->assertSame($fakeBase.'/storage/app/public', readlink($fakeBase.'/public/storage')); + } + + /** + * public/storage 부재 + Laravel storage:link source(storage/app/public) 도 부재 → + * 표준 미사용 환경으로 보고 skip (false positive 차단). + */ + public function test_recover_public_storage_symlink_skips_when_both_absent(): void + { + $fakeBase = $this->arrangeFakeBase(); + File::ensureDirectoryExists($fakeBase.'/public'); + // public/storage 와 storage/app/public 모두 의도적 미생성 + + $this->invokeRecoverPublicStorageSymlink(); + + $this->assertFalse(is_link($fakeBase.'/public/storage'), 'source 부재 시 자동 생성 안 함'); $this->assertFalse(file_exists($fakeBase.'/public/storage'), 'public/storage 자체가 생성되지 않아야 한다'); } diff --git a/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php b/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php index 2a59e048..65c10aa8 100644 --- a/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php +++ b/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php @@ -41,7 +41,7 @@ class FilePermissionHelperSymlinkTest extends TestCase } #[Test] - public function copyDirectory_는_symlink_를_target_추적_없이_그대로_보존합니다(): void + public function copy_directory_는_symlink_를_target_추적_없이_그대로_보존합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -72,7 +72,7 @@ class FilePermissionHelperSymlinkTest extends TestCase } #[Test] - public function copyDirectory_는_기존_dest_디렉토리를_symlink_로_교체할_때_dest_먼저_정리합니다(): void + public function copy_directory_는_기존_dest_디렉토리를_symlink_로_교체할_때_dest_먼저_정리합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -97,7 +97,7 @@ class FilePermissionHelperSymlinkTest extends TestCase } #[Test] - public function removeOrphanItems_는_source_부재_symlink_를_unlink_로_삭제합니다(): void + public function remove_orphan_items_는_source_부재_symlink_를_unlink_로_삭제합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -119,8 +119,98 @@ class FilePermissionHelperSymlinkTest extends TestCase $this->assertFileExists($linkTarget.'/precious.txt', 'symlink target 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)'); } + /** + * 보호 화이트리스트(`preserveLinkPaths`)에 매칭되는 orphan symlink 는 삭제하지 않고 보존. + * + * 코어 업데이트 prune 모드에서 `public/storage` symlink 가 릴리즈 소스에 없다는 이유로 + * orphan 삭제되어 업로드 파일이 404 되던 결함(#43) 회귀 가드. + */ #[Test] - public function copyDirectory_는_심볼릭링크_없는_일반_디렉토리는_기존_동작을_유지합니다(): void + public function remove_orphan_items_는_화이트리스트_symlink_를_orphan_이어도_보존합니다(): void + { + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/uploaded.txt', '업로드 파일'); + + // dest 에만 존재하는 storage symlink (source 에는 없음 → orphan) + if (! @symlink($linkTarget, $dest.'/storage')) { + $this->markTestSkipped('symlink 생성 실패'); + } + + // preserveLinkPaths=['storage'] 로 prune 실행 → 보호되어야 함 + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true, false, null, ['storage']); + + $this->assertTrue(is_link($dest.'/storage'), '화이트리스트 symlink 는 orphan 이어도 보존되어야 한다'); + $this->assertSame($linkTarget, readlink($dest.'/storage')); + $this->assertFileExists($linkTarget.'/uploaded.txt', 'symlink target 파일 보존'); + } + + /** + * 화이트리스트 밖 orphan symlink 는 `preserveLinkPaths=['storage']` 여도 여전히 삭제. + * + * 정밀 보호(무조건 보존 아님)임을 보장 — `public/storage` 만 좁게 보존한다는 설계 확정. + */ + #[Test] + public function remove_orphan_items_는_화이트리스트_밖_symlink_는_여전히_삭제합니다(): void + { + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/precious.txt', '중요한 파일'); + + // dest 에만 존재하는 다른 이름의 orphan symlink + if (! @symlink($linkTarget, $dest.'/other_link')) { + $this->markTestSkipped('symlink 생성 실패'); + } + + // 화이트리스트는 'storage' 만 — 'other_link' 는 보호 대상 아님 + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true, false, null, ['storage']); + + $this->assertFalse(is_link($dest.'/other_link'), '화이트리스트 밖 orphan symlink 는 삭제되어야 한다'); + $this->assertFileExists($linkTarget.'/precious.txt', 'symlink target 파일은 보존 (재귀 삭제 사고 차단)'); + } + + /** + * 보호 화이트리스트에 매칭되는 orphan junction 도 보존 (Windows 전용). + */ + #[Test] + public function remove_orphan_items_는_화이트리스트_junction_을_orphan_이어도_보존합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + $source = $this->tempRoot.'/src'; + $dest = $this->tempRoot.'/dst'; + $linkTarget = $this->tempRoot.'/link_target'; + + File::ensureDirectoryExists($source); + File::ensureDirectoryExists($dest); + File::ensureDirectoryExists($linkTarget); + File::put($linkTarget.'/uploaded.txt', '업로드 파일'); + + exec('cmd /c mklink /J '.escapeshellarg($dest.'/storage').' '.escapeshellarg($linkTarget).' 2>&1', $out, $code); + if ($code !== 0 || ! is_dir($dest.'/storage')) { + $this->markTestSkipped('junction 생성 실패: '.implode("\n", $out)); + } + + FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true, false, null, ['storage']); + + $this->assertDirectoryExists($dest.'/storage', '화이트리스트 junction 은 orphan 이어도 보존되어야 한다'); + $this->assertFileExists($dest.'/storage/uploaded.txt', 'junction 통과 시 target 파일 접근 가능'); + } + + #[Test] + public function copy_directory_는_심볼릭링크_없는_일반_디렉토리는_기존_동작을_유지합니다(): void { $source = $this->tempRoot.'/src'; $dest = $this->tempRoot.'/dst'; @@ -145,7 +235,7 @@ class FilePermissionHelperSymlinkTest extends TestCase * junction 은 Windows 전용이므로 비-Windows 환경에서는 skip. */ #[Test] - public function copyDirectory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): void + public function copy_directory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): void { if (PHP_OS_FAMILY !== 'Windows') { $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); @@ -182,7 +272,7 @@ class FilePermissionHelperSymlinkTest extends TestCase * 링크 자체만 제거하는지 검증 (junction 은 isDir() 이 false 라 별도 rmdir 경로 필요). */ #[Test] - public function removeOrphanItems_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): void + public function remove_orphan_items_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): void { if (PHP_OS_FAMILY !== 'Windows') { $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); diff --git a/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php b/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php new file mode 100644 index 00000000..84856f7f --- /dev/null +++ b/tests/Unit/Extension/Helpers/StorageLinkHelperTest.php @@ -0,0 +1,265 @@ +setBasePath()` 로 + * 격리된 fake base 를 구성하여 `public_path()`/`storage_path()` 가 임시 트리를 가리키게 한다. + * + * Windows: PHP `symlink()` 는 `SeCreateSymbolicLink` 권한 필요. 권한 부족 환경에서는 + * junction 폴백이 작동하거나(둘 다 실패 시) markTestSkipped 로 건너뛴다. + */ +class StorageLinkHelperTest extends TestCase +{ + private string $fakeBase; + + private ?string $originalBasePath = null; + + protected function setUp(): void + { + parent::setUp(); + + $this->originalBasePath = base_path(); + $this->fakeBase = storage_path('app/testing/storage_link_helper_'.uniqid('', true)); + File::ensureDirectoryExists($this->fakeBase); + app()->setBasePath($this->fakeBase); + } + + protected function tearDown(): void + { + if ($this->originalBasePath !== null) { + app()->setBasePath($this->originalBasePath); + } + + if (File::isDirectory($this->fakeBase)) { + // symlink/junction 은 target 을 따라가지 않도록 링크만 먼저 제거 (재귀 삭제 사고 차단). + // 백업으로 생긴 `storage.broken.*` 링크까지 포함해 public/ 하위 링크를 모두 정리한다. + $publicDir = $this->fakeBase.'/public'; + if (is_dir($publicDir)) { + foreach (scandir($publicDir) as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $path = $publicDir.'/'.$entry; + if (is_link($path)) { + @unlink($path); + } elseif (PHP_OS_FAMILY === 'Windows' && ! is_file($path) && @readlink($path) !== false) { + // Windows junction — rmdir 로 링크만 제거 (target 미추적) + @rmdir($path); + } + } + } + File::deleteDirectory($this->fakeBase); + } + + parent::tearDown(); + } + + /** + * symlink 생성 권한이 없으면 테스트를 건너뛴다 (Windows 일반 사용자). + */ + private function skipIfNoSymlink(): void + { + $probe = $this->fakeBase.'/symlink_probe'; + $target = $this->fakeBase.'/symlink_probe_target'; + File::ensureDirectoryExists($target); + if (! @symlink($target, $probe)) { + $this->markTestSkipped('symlink 생성 권한 부족 (Windows SeCreateSymbolicLink)'); + } + @unlink($probe); + File::deleteDirectory($target); + } + + #[Test] + public function 이미_정상_symlink_이면_no_op_으로_보존합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + @symlink($this->fakeBase.'/storage/app/public', $this->fakeBase.'/public/storage'); + + $targetBefore = readlink($this->fakeBase.'/public/storage'); + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertTrue(is_link($this->fakeBase.'/public/storage'), '정상 symlink 는 보존되어야 한다'); + $this->assertSame($targetBefore, readlink($this->fakeBase.'/public/storage'), 'target 미변경 (멱등)'); + } + + #[Test] + public function public_storage_부재_시_symlink_를_신규_생성합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + // public/storage 의도적 미생성 — prune 이 삭제한 시나리오 + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertTrue(is_link($this->fakeBase.'/public/storage'), '부재 시 symlink 가 신규 생성되어야 한다'); + $this->assertSame($this->fakeBase.'/storage/app/public', readlink($this->fakeBase.'/public/storage')); + } + + #[Test] + public function 일반_디렉토리_손상_시_broken_백업_후_재생성합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public/storage'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + + // 손상 상태: public/storage 가 dereferenced 콘텐츠를 가진 일반 디렉토리 + File::put($this->fakeBase.'/public/storage/uploaded.txt', 'dereferenced content'); + + StorageLinkHelper::ensurePublicStorageLink(); + + // 1) symlink 로 재생성 + $this->assertTrue(is_link($this->fakeBase.'/public/storage'), 'symlink 로 재생성되어야 한다'); + $this->assertSame($this->fakeBase.'/storage/app/public', readlink($this->fakeBase.'/public/storage')); + + // 2) .broken.{timestamp} 백업 존재 + 콘텐츠 보존 + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertCount(1, $backups, '.broken.{timestamp} 백업 1개 존재'); + $this->assertFileExists($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt'); + $this->assertSame('dereferenced content', File::get($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt')); + } + + #[Test] + public function storage_app_public_source_부재_시_skip_합니다(): void + { + File::ensureDirectoryExists($this->fakeBase.'/public/storage'); + File::put($this->fakeBase.'/public/storage/custom.txt', 'operator content'); + // storage/app/public 의도적 미생성 — Laravel 표준 미사용 환경 + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertFalse(is_link($this->fakeBase.'/public/storage'), 'source 부재 시 symlink 생성 안 함'); + $this->assertTrue(is_dir($this->fakeBase.'/public/storage'), '일반 디렉토리 그대로 유지'); + $this->assertFileExists($this->fakeBase.'/public/storage/custom.txt', '운영자 콘텐츠 보존'); + + // rename 백업도 없어야 함 + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertEmpty($backups, 'source 부재 시 rename 백업도 생성되지 않아야 한다'); + } + + #[Test] + public function 로거_null_이면_upgrade_채널_폴백으로_예외없이_동작합니다(): void + { + $this->skipIfNoSymlink(); + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + + // null 로거로 호출 — 폴백 채널로 예외 없이 완료되어야 함 + StorageLinkHelper::ensurePublicStorageLink(null); + + $this->assertTrue(is_link($this->fakeBase.'/public/storage')); + } + + /** + * public/storage 부재 시 junction 폴백으로 재생성 (Windows — symlink 권한 불요). + * + * Windows 일반 사용자는 symlink 를 못 만들지만 junction(`mklink /J`)은 만들 수 있으므로, + * `createLink` 의 junction 폴백이 부재 케이스를 실제로 복구하는지 검증한다. + */ + #[Test] + public function public_storage_부재_시_junction_폴백으로_재생성합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + File::put($this->fakeBase.'/storage/app/public/uploaded.txt', 'user upload'); + // public/storage 의도적 미생성 + + StorageLinkHelper::ensurePublicStorageLink(); + + $this->assertTrue(is_dir($this->fakeBase.'/public/storage'), '부재 시 junction 으로 재생성되어야 한다'); + $this->assertFileExists($this->fakeBase.'/public/storage/uploaded.txt', 'junction 통과 시 업로드 파일 접근 가능'); + } + + /** + * 일반 디렉토리 손상 시 junction 폴백으로 재생성 + .broken 백업 (Windows). + */ + #[Test] + public function 일반_디렉토리_손상_시_junction_폴백으로_재생성합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + File::ensureDirectoryExists($this->fakeBase.'/public/storage'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + File::put($this->fakeBase.'/public/storage/uploaded.txt', 'dereferenced content'); + + // 재생성 전 source 에 마커 파일을 넣어, 재생성된 링크가 source 를 통과하는지로 검증한다. + File::put($this->fakeBase.'/storage/app/public/marker.txt', 'from source'); + + StorageLinkHelper::ensurePublicStorageLink(); + + // junction 으로 재생성 → source(storage/app/public) 를 통과해 marker.txt 접근 가능 + $this->assertTrue(is_dir($this->fakeBase.'/public/storage'), 'junction 으로 재생성되어야 한다'); + $this->assertSame( + 'from source', + File::get($this->fakeBase.'/public/storage/marker.txt'), + '재생성된 링크는 storage/app/public 을 가리켜야 한다 (source 마커 접근 가능)', + ); + + // .broken 백업 존재 + dereferenced 콘텐츠 보존 + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertCount(1, $backups, '.broken 백업 1개 존재'); + $this->assertFileExists($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt'); + $this->assertSame('dereferenced content', File::get($this->fakeBase.'/public/'.$backups[0].'/uploaded.txt')); + } + + /** + * 이미 junction(정상 링크)이면 no-op — rename 백업하지 않고 보존 (Windows). + */ + #[Test] + public function 이미_junction_이면_no_op_으로_보존합니다(): void + { + if (PHP_OS_FAMILY !== 'Windows') { + $this->markTestSkipped('junction 은 Windows 전용 (mklink /J)'); + } + + File::ensureDirectoryExists($this->fakeBase.'/public'); + File::ensureDirectoryExists($this->fakeBase.'/storage/app/public'); + File::put($this->fakeBase.'/storage/app/public/uploaded.txt', 'user upload'); + + exec('cmd /c mklink /J '.escapeshellarg($this->fakeBase.'/public/storage').' '.escapeshellarg($this->fakeBase.'/storage/app/public').' 2>&1', $out, $code); + if ($code !== 0 || ! is_dir($this->fakeBase.'/public/storage')) { + $this->markTestSkipped('junction 생성 실패: '.implode("\n", $out)); + } + + StorageLinkHelper::ensurePublicStorageLink(); + + // .broken 백업이 생기지 않아야 함 (no-op) + $backups = array_values(array_filter( + scandir($this->fakeBase.'/public'), + fn ($e) => str_starts_with($e, 'storage.broken.'), + )); + $this->assertEmpty($backups, '이미 정상 junction 은 rename 백업 없이 보존되어야 한다'); + $this->assertFileExists($this->fakeBase.'/public/storage/uploaded.txt'); + } +} diff --git a/tests/Unit/Services/CoreUpdateServiceTest.php b/tests/Unit/Services/CoreUpdateServiceTest.php index 4a55bc20..646a5ddb 100644 --- a/tests/Unit/Services/CoreUpdateServiceTest.php +++ b/tests/Unit/Services/CoreUpdateServiceTest.php @@ -1291,6 +1291,69 @@ MD; } } + /** + * end-to-end 회귀 (#43): `applyUpdate(prune:true)` 가 `public` 타깃을 정리할 때 + * `public/storage` symlink 를 orphan 으로 삭제하지 않고 보존합니다. + * + * 릴리즈 소스(`public/`)에는 런타임 symlink 가 없으므로 prune 이 orphan 으로 판정하여 + * `@unlink` 하던 결함으로 업로드 파일이 404 되던 회귀를 차단한다. 층1 화이트리스트 + * 전파(`public` → `preserveLinkPaths: ['storage']`) 합류 지점 검증. + */ + public function test_apply_update_prune_preserves_public_storage_symlink(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['public']); + + try { + // source(릴리즈): public/ 에 일반 파일만, storage symlink 는 없음 + File::ensureDirectoryExists($source.DIRECTORY_SEPARATOR.'public'); + File::put($source.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php', "&1', $out, $code); + $created = $code === 0 && is_dir($linkPath); + } + if (! $created) { + $this->markTestSkipped('symlink/junction 생성 권한 부족 (Windows SeCreateSymbolicLink)'); + } + + $this->service->applyUpdate($source, null, prune: true, applyList: null); + + // symlink/junction 이 orphan 삭제되지 않고 보존되어야 함. Windows junction 은 + // is_dir()/is_link() 이 stat 캐시에 따라 불안정하므로, 링크 존재는 readlink 로, + // 접근성은 업로드 파일 존재로 검증한다 (#43 — 404 회귀의 실측 지표). + clearstatcache(true, $linkPath); + $this->assertNotFalse( + @readlink($linkPath), + 'public/storage 링크는 prune 후에도 보존되어야 한다 (#43)', + ); + $this->assertFileExists( + $linkPath.DIRECTORY_SEPARATOR.'uploaded.txt', + 'symlink 통과 시 업로드 파일에 접근 가능해야 한다 (404 회귀 차단)', + ); + + // 코어 변경분(index.php)은 정상 반영 + $this->assertStringContainsString('core', File::get($fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'index.php')); + } finally { + // 링크는 target 을 따라가지 않도록 먼저 제거 (tearDown 재귀 삭제 사고 차단). + // symlink → unlink, Windows junction → rmdir. + $linkPath = $fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'storage'; + if (is_link($linkPath)) { + @unlink($linkPath); + } elseif (PHP_OS_FAMILY === 'Windows' && @readlink($linkPath) !== false && ! @rmdir($linkPath)) { + @unlink($linkPath); + } + $restore(); + } + } + /** * applyList=null (백업 부재 fallback): prune 이 아니어도 전체 덮어쓰기로 회귀. */ diff --git a/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php b/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php index 341310e4..94583580 100644 --- a/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php +++ b/upgrades/data/7.0.0-beta.5/migrations/05_RecoverPublicStorageSymlink.php @@ -2,6 +2,7 @@ namespace App\Upgrades\Data\V7_0_0_beta_5\Migrations; +use App\Extension\Helpers\StorageLinkHelper; use App\Extension\Upgrade\DataMigration; use App\Extension\UpgradeContext; @@ -13,12 +14,14 @@ use App\Extension\UpgradeContext; * symlink 가 일반 디렉토리로 변질되는 결함이 잔존한다. 본 step 이 단발성 자동 복구 * (rename 보존 + symlink 재생성) 로 보완한다. * - * 안전 가드 (false positive 차단): - * 1. `is_link($publicStorage)` 가 true → 이미 정상 symlink, silent skip (멱등) - * 2. `! is_dir($publicStorage) || ! is_dir($storageSource)` → Laravel storage:link - * 컨벤션을 따르지 않는 환경, skip - * 3. rename 으로 `.broken.{timestamp}` 백업 후 symlink 재생성 — `rm -rf` 미사용 - * 4. symlink 생성 실패 (Windows 권한 부족 등) → rename 백업을 원위치로 복원 + * 복구 로직은 `StorageLinkHelper::ensurePublicStorageLink()` 로 일원화되어 있다 (#43 에서 + * 코어 업데이트 종료 시점과 공유). 헬퍼가 멱등이며 "부재→재생성" 케이스까지 처리하므로 + * 본 step 의 기존 동작(정상 symlink skip / 손상 디렉토리 rename+재생성 / 실패 원복)은 + * 상위호환으로 유지된다. + * + * V-1 안전: `StorageLinkHelper` 는 `Illuminate\Support\Facades\*` + 네이티브 함수 + 로컬 + * 로직만 사용하는 정적 헬퍼이므로, 이 step 이 이전 버전 프로세스 메모리에서 in-process + * fallback 으로 실행되어도 stale 인스턴스 의존이 없다. */ final class RecoverPublicStorageSymlink implements DataMigration { @@ -29,45 +32,6 @@ final class RecoverPublicStorageSymlink implements DataMigration public function run(UpgradeContext $context): void { - $publicStorage = public_path('storage'); - $storageSource = storage_path('app/public'); - - // 1) 정상 symlink → skip (멱등) - if (is_link($publicStorage)) { - return; - } - - // 2) public/storage 가 일반 디렉토리이면서 Laravel storage:link source 가 존재할 때만 - // 손상 후보로 판정 — 운영자가 Laravel 표준을 따르고 있다는 강한 신호. - // 둘 중 하나라도 부재하면 운영자 의도적 구성으로 간주하여 skip. - if (! is_dir($publicStorage) || ! is_dir($storageSource)) { - return; - } - - // 3) 안전 보존 — rm -rf 대신 rename 으로 백업 후 symlink 재생성. - // false positive (운영자가 의도적으로 일반 디렉토리 사용) 시에도 데이터 손실 없음. - $backup = $publicStorage.'.broken.'.date('YmdHis'); - if (! @rename($publicStorage, $backup)) { - $context->logger->warning('[7.0.0-beta.5] public/storage rename 실패 — 자동 복구 skip', [ - 'path' => $publicStorage, - ]); - - return; - } - - if (! @symlink($storageSource, $publicStorage)) { - // symlink 생성 실패 (Windows SeCreateSymbolicLink 권한 부족 등) → rename 원복 - @rename($backup, $publicStorage); - $context->logger->warning('[7.0.0-beta.5] public/storage symlink 재생성 실패 — rename 원복', [ - 'target' => $storageSource, - ]); - - return; - } - - $context->logger->info('[7.0.0-beta.5] public/storage symlink 자동 복구 완료 — 백업 디렉토리 검증 후 수동 삭제 권장', [ - 'backup' => $backup, - 'target' => $storageSource, - ]); + StorageLinkHelper::ensurePublicStorageLink($context->logger); } }