Files
Gnuboard7/tests/Unit/Extension/Helpers/FilePermissionHelperSymlinkTest.php
T
HeuJung 0a6ff23132 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 는 이 헬퍼로 위임(상위호환)
2026-07-16 12:42:54 +09:00

302 lines
13 KiB
PHP

<?php
namespace Tests\Unit\Extension\Helpers;
use App\Extension\Helpers\FilePermissionHelper;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
/**
* `FilePermissionHelper::copyDirectory` / `removeOrphanItems` 의 symlink 보존 동작 회귀 가드.
*
* 회귀 시나리오 (gnuboard/g7#28):
* - 코어 자동 롤백 시 `public/storage` symlink 가 target 디렉토리 내용으로 추적 복사되어
* symlink 가 일반 디렉토리로 변질 → 운영자가 `storage:link` 재실행 필요
* - `removeOrphanItems` 가 source 부재 symlink 를 디렉토리로 인식하여 target 의 모든 파일을
* 재귀 삭제하는 사고 가능성
*
* Windows: PHP `symlink()` 는 `SeCreateSymbolicLink` 권한 필요. 일반 사용자 환경에서는
* 권한 부족으로 symlink 생성 자체가 실패할 가능성이 높으므로 symlink 가 생성되지 않으면 skip.
*/
class FilePermissionHelperSymlinkTest extends TestCase
{
private string $tempRoot;
protected function setUp(): void
{
parent::setUp();
$this->tempRoot = storage_path('app/testing/file_permission_symlink_'.uniqid('', true));
File::ensureDirectoryExists($this->tempRoot);
}
protected function tearDown(): void
{
if (File::isDirectory($this->tempRoot)) {
File::deleteDirectory($this->tempRoot);
}
parent::tearDown();
}
#[Test]
public function copy_directory_는_symlink_를_target_추적_없이_그대로_보존합니다(): void
{
$source = $this->tempRoot.'/src';
$dest = $this->tempRoot.'/dst';
$linkTarget = $this->tempRoot.'/link_target';
File::ensureDirectoryExists($source);
File::ensureDirectoryExists($linkTarget);
File::put($linkTarget.'/inside.txt', '링크 대상 안 파일');
$linkPath = $source.'/storage';
if (! @symlink($linkTarget, $linkPath)) {
$this->markTestSkipped('symlink 생성 실패 (Windows SeCreateSymbolicLink 권한 부족 가능)');
}
FilePermissionHelper::copyDirectory($source, $dest);
$this->assertTrue(is_link($dest.'/storage'), 'dest 의 storage 는 symlink 로 유지되어야 한다');
$this->assertSame($linkTarget, readlink($dest.'/storage'));
// target 내용이 dest 의 storage 디렉토리에 복사되어선 안 된다 — symlink 통한 간접 접근만 허용
// 검증: dest/storage 를 link 가 아닌 일반 디렉토리로 직접 열어보면 0 entry
$entries = scandir($dest.'/storage');
$entries = array_values(array_diff($entries, ['.', '..']));
// 링크가 살아있다면 symlink 통과해서 inside.txt 가 보임 (정상)
$this->assertContains('inside.txt', $entries, 'symlink 통과 시 target 의 inside.txt 접근 가능');
// 그러나 dest/storage 자체는 디렉토리 inode 가 아닌 link
$this->assertTrue(is_link($dest.'/storage'));
}
#[Test]
public function copy_directory_는_기존_dest_디렉토리를_symlink_로_교체할_때_dest_먼저_정리합니다(): void
{
$source = $this->tempRoot.'/src';
$dest = $this->tempRoot.'/dst';
$linkTarget = $this->tempRoot.'/link_target';
File::ensureDirectoryExists($source);
File::ensureDirectoryExists($linkTarget);
File::put($linkTarget.'/inside.txt', 'x');
$linkPath = $source.'/storage';
if (! @symlink($linkTarget, $linkPath)) {
$this->markTestSkipped('symlink 생성 실패');
}
// dest 에 동일 이름의 일반 디렉토리 미리 생성 (백업 복원 직전 상태 시뮬)
File::ensureDirectoryExists($dest.'/storage');
File::put($dest.'/storage/stale.txt', '구버전 잔재');
FilePermissionHelper::copyDirectory($source, $dest);
$this->assertTrue(is_link($dest.'/storage'), '기존 디렉토리는 symlink 로 교체되어야 한다');
}
#[Test]
public function remove_orphan_items_는_source_부재_symlink_를_unlink_로_삭제합니다(): 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 에만 존재하는 symlink (source 에는 없음)
if (! @symlink($linkTarget, $dest.'/orphan_link')) {
$this->markTestSkipped('symlink 생성 실패');
}
FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true);
$this->assertFalse(is_link($dest.'/orphan_link'), 'orphan symlink 는 제거되어야 한다');
$this->assertFileExists($linkTarget.'/precious.txt', 'symlink target 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)');
}
/**
* 보호 화이트리스트(`preserveLinkPaths`)에 매칭되는 orphan symlink 는 삭제하지 않고 보존.
*
* 코어 업데이트 prune 모드에서 `public/storage` symlink 가 릴리즈 소스에 없다는 이유로
* orphan 삭제되어 업로드 파일이 404 되던 결함(#43) 회귀 가드.
*/
#[Test]
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';
File::ensureDirectoryExists($source.'/sub');
File::put($source.'/file.txt', 'hi');
File::put($source.'/sub/nested.txt', 'world');
FilePermissionHelper::copyDirectory($source, $dest);
$this->assertSame('hi', File::get($dest.'/file.txt'));
$this->assertSame('world', File::get($dest.'/sub/nested.txt'));
}
/**
* Windows JUNCTION (`mklink /J`) 는 PHP 의 `SplFileInfo::isLink()` / `isDir()` 이
* 모두 false 를 반환하는 reparse point 다. `php artisan storage:link` 가 Windows 에서
* 생성하는 `public/storage` 가 이 형태이며, 이를 파일로 오판하면 `copyFile` 이
* 디렉토리를 `copy()` 에 넘겨 `copy(): The first argument ... cannot be a directory` 로
* 코어 업데이트가 실패한다 (회귀 가드).
*
* junction 은 Windows 전용이므로 비-Windows 환경에서는 skip.
*/
#[Test]
public function copy_directory_는_windows_junction_을_파일로_오판하지_않고_보존합니다(): 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($linkTarget);
File::put($linkTarget.'/inside.txt', 'junction 대상 안 파일');
$junctionPath = $source.'/storage';
// mklink /J 는 관리자 권한 없이 생성 가능 (심볼릭 링크와 달리 SeCreateSymbolicLink 불필요)
exec('cmd /c mklink /J '.escapeshellarg($junctionPath).' '.escapeshellarg($linkTarget).' 2>&1', $out, $code);
if ($code !== 0 || ! is_dir($junctionPath)) {
$this->markTestSkipped('junction 생성 실패: '.implode("\n", $out));
}
// 사전 조건: PHP 가 junction 을 link/dir 어느 쪽으로도 인식하지 못함을 확인
$this->assertFalse(is_link($junctionPath), 'junction 은 is_link() 가 false (전제)');
// copyFile 이 디렉토리를 받아 던지던 예외 없이 완료되어야 한다
FilePermissionHelper::copyDirectory($source, $dest);
// junction 은 dest 에도 junction 으로 보존되어 target 내용에 접근 가능해야 한다
$this->assertTrue(is_dir($dest.'/storage'), 'dest 의 storage 는 디렉토리(junction)로 접근 가능해야 한다');
$this->assertFileExists($dest.'/storage/inside.txt', 'junction 통과 시 target 파일 접근 가능');
}
/**
* removeOrphanItems 가 source 부재 junction 을 재귀 삭제(target 내용 삭제)하지 않고
* 링크 자체만 제거하는지 검증 (junction 은 isDir() 이 false 라 별도 rmdir 경로 필요).
*/
#[Test]
public function remove_orphan_items_는_source_부재_junction_을_target_추적_없이_링크만_제거합니다(): 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.'/precious.txt', '중요한 파일');
// dest 에만 존재하는 junction (source 에는 없음)
exec('cmd /c mklink /J '.escapeshellarg($dest.'/orphan_link').' '.escapeshellarg($linkTarget).' 2>&1', $out, $code);
if ($code !== 0 || ! is_dir($dest.'/orphan_link')) {
$this->markTestSkipped('junction 생성 실패: '.implode("\n", $out));
}
FilePermissionHelper::copyDirectory($source, $dest, null, [], '', true);
$this->assertDirectoryDoesNotExist($dest.'/orphan_link', 'orphan junction 은 제거되어야 한다');
$this->assertFileExists($linkTarget.'/precious.txt', 'junction target 의 파일은 보존되어야 한다 (재귀 삭제 사고 차단)');
}
}