fix(core-update): --prune 시 public/storage symlink 삭제로 업로드 파일 404 되는 문제
--prune 은 릴리즈 소스에 없는 public/storage 런타임 symlink 를 orphan 으로 삭제해 업로드 파일이 404 되었다. 심층 방어 2층으로 차단한다. - 층1(예방): FilePermissionHelper 에 preserveLinkPaths 화이트리스트 추가. applyUpdate 가 public 타깃에 ['storage'] 전달 → 매칭 orphan 링크/junction 만 삭제 제외 (정밀 보호) - 층2(복구): StorageLinkHelper::ensurePublicStorageLink 신규 정적 헬퍼. CoreUpdateCommand 종료 시점(정상+핸드오프) 호출로 부재/손상 링크 멱등 재생성 (Windows junction 폴백). beta.5 migration 05 는 이 헬퍼로 위임(상위호환)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -33,12 +33,17 @@ class FilePermissionHelper
|
||||
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
|
||||
* @param bool $preserveTopLevelOrphans 최상위 한 레벨의 orphan 보존 여부
|
||||
* @param array<string, bool>|null $applyList 적용 대상 파일 상대경로 화이트리스트
|
||||
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
|
||||
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
|
||||
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
|
||||
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
|
||||
* (이 디렉토리 루트 기준, `'sub/file.txt' => true` 형태의 lookup 맵). null 이면
|
||||
* 전체 파일 복사(기존 동작). 지정 시 이 맵에 없는 파일은 복사를 스킵한다 — 코어
|
||||
* 업데이트 증분 모드에서 "코어가 변경하지 않은 파일"을 건드리지 않기 위함. symlink
|
||||
* 는 이 필터의 영향을 받지 않고 항상 재생성된다(링크는 목록 산출 대상이 아님).
|
||||
* @param array<int, string> $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<int, string> $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() 보다 먼저 평가되어야 한다.
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
/**
|
||||
* `public/storage` symlink 멱등 복구 헬퍼.
|
||||
*
|
||||
* 코어 업데이트(특히 `--prune`) 이후 `public/storage` symlink 가 부재/손상 상태로 남는
|
||||
* 결함(#43)을 종료 시점에 방어 복구한다. `is_link` 이면 no-op, 부재/일반 디렉토리이면
|
||||
* `storage/app/public` 을 가리키는 symlink 를 (필요 시 백업 후) 재생성한다.
|
||||
*
|
||||
* V-1 안전: 이 헬퍼는 beta.5 DataMigration `RecoverPublicStorageSymlink` 가 in-process
|
||||
* fallback 으로 호출할 수 있으므로, `Illuminate\Support\Facades\*` 파사드 + 네이티브
|
||||
* 함수 + 로컬 로직만 사용한다 (신규 Service/Manager/Repository 메서드 의존 금지).
|
||||
*
|
||||
* 로거 주입: 두 호출자의 로깅 채널이 다르다 — migration 은 `$context->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);
|
||||
}
|
||||
}
|
||||
@@ -904,7 +904,7 @@ class CoreUpdateService
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
* @param bool $prune true 면 전체 덮어쓰기 + orphan 삭제(기존 동작), false 면 증분 적용
|
||||
* @param array<int, string>|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 — 증분 모드에서는 적용 목록에 있을 때만 복사.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 자체가 생성되지 않아야 한다');
|
||||
}
|
||||
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Extension\Helpers;
|
||||
|
||||
use App\Extension\Helpers\StorageLinkHelper;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* `StorageLinkHelper::ensurePublicStorageLink` 멱등 복구 동작 회귀 가드.
|
||||
*
|
||||
* 코어 업데이트 `--prune` 이후 `public/storage` symlink 가 부재/손상 상태로 남아 업로드
|
||||
* 파일이 404 되던 결함(#43)의 종료 시점 방어 복구를 검증한다. `app()->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');
|
||||
}
|
||||
}
|
||||
@@ -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', "<?php // core\n");
|
||||
|
||||
// 활성(mine): public/storage symlink + 업로드 파일 target
|
||||
File::ensureDirectoryExists($fakeBase.DIRECTORY_SEPARATOR.'public');
|
||||
$linkTarget = $fakeBase.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'public';
|
||||
File::ensureDirectoryExists($linkTarget);
|
||||
File::put($linkTarget.DIRECTORY_SEPARATOR.'uploaded.txt', 'user upload');
|
||||
|
||||
$linkPath = $fakeBase.DIRECTORY_SEPARATOR.'public'.DIRECTORY_SEPARATOR.'storage';
|
||||
$created = @symlink($linkTarget, $linkPath);
|
||||
if (! $created && PHP_OS_FAMILY === 'Windows') {
|
||||
exec('cmd /c mklink /J '.escapeshellarg($linkPath).' '.escapeshellarg($linkTarget).' 2>&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 이 아니어도 전체 덮어쓰기로 회귀.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user