getFilename(); $itemRelativePath = $relativePath === '' ? $itemName : $relativePath.'/'.$itemName; if (static::isExcluded($itemName, $itemRelativePath, $excludes)) { continue; } $destPath = $destination.DIRECTORY_SEPARATOR.$itemName; if ($item->isDir()) { static::copyDirectory($item->getPathname(), $destPath, $onProgress, $excludes, $itemRelativePath, $removeOrphans); } else { static::copyFile($item->getPathname(), $destPath); } } // 소스에 없는 대상 파일/디렉토리 삭제 if ($removeOrphans && File::isDirectory($destination)) { static::removeOrphanItems($source, $destination, $excludes, $relativePath); } } /** * 소스에 없고 대상에만 있는 파일/디렉토리를 삭제합니다. * * excludes 목록에 해당하는 항목은 삭제하지 않습니다. * * @param string $source 소스 디렉토리 경로 * @param string $destination 대상 디렉토리 경로 * @param array $excludes 제외할 이름 또는 경로 목록 * @param string $relativePath 현재 상대 경로 * @return void */ protected static function removeOrphanItems(string $source, string $destination, array $excludes, string $relativePath): void { $destItems = new \FilesystemIterator($destination, \FilesystemIterator::SKIP_DOTS); foreach ($destItems as $destItem) { $itemName = $destItem->getFilename(); $itemRelativePath = $relativePath === '' ? $itemName : $relativePath.'/'.$itemName; // excludes 대상은 삭제하지 않음 if (static::isExcluded($itemName, $itemRelativePath, $excludes)) { continue; } $srcPath = $source.DIRECTORY_SEPARATOR.$itemName; // 소스에 존재하지 않는 항목만 삭제 if (! File::exists($srcPath) && ! File::isDirectory($srcPath)) { if ($destItem->isDir()) { File::deleteDirectory($destItem->getPathname()); } else { File::delete($destItem->getPathname()); } } } } /** * 항목이 제외 대상인지 확인합니다. * * - 단순 이름 (슬래시 미포함): 모든 레벨에서 해당 이름과 매칭 * - 경로 패턴 (슬래시 포함): 상대 경로와 정확히 매칭 * * @param string $itemName 현재 항목의 파일/디렉토리 이름 * @param string $itemRelativePath 루트로부터의 상대 경로 * @param array $excludes 제외 목록 * @return bool 제외 대상 여부 */ public static function isExcluded(string $itemName, string $itemRelativePath, array $excludes): bool { foreach ($excludes as $exclude) { if (str_contains($exclude, '/')) { // 경로 패턴: 상대 경로와 정확히 매칭 if ($itemRelativePath === $exclude) { return true; } } else { // 단순 이름: 모든 레벨에서 매칭 if ($itemName === $exclude) { return true; } } } return false; } /** * 퍼미션과 소유권을 보존하면서 파일을 복사합니다. * * - 기존 파일: 복사 후 원래 퍼미션/소유자/그룹 복원 * - 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (퍼미션은 PHP 기본 umask) * * 신규 파일에 부모 소유권을 상속시키는 이유는 sudo 로 실행된 업데이트가 root 소유로 * 파일을 생성하는 문제를 방지하기 위함이다. vendor/ 내부처럼 cleanDirectory 후 * 전량 재생성되는 경로에서 필요하다. * * @param string $source 소스 파일 * @param string $destination 대상 파일 * @return void */ public static function copyFile(string $source, string $destination): void { $isExisting = File::exists($destination); $existingPerms = null; $existingOwner = null; $existingGroup = null; if ($isExisting) { $existingPerms = fileperms($destination); $existingOwner = fileowner($destination); $existingGroup = filegroup($destination); } File::ensureDirectoryExists(dirname($destination)); File::copy($source, $destination); if ($isExisting) { // 기존 파일: 원래 퍼미션/소유권 복원 if ($existingPerms !== null) { @chmod($destination, $existingPerms); } if ($existingOwner !== null && function_exists('chown')) { @chown($destination, $existingOwner); } if ($existingGroup !== null && function_exists('chgrp')) { @chgrp($destination, $existingGroup); } } else { // 신규 파일: 부모 디렉토리의 소유자/그룹 상속 static::inheritOwnershipFromParent($destination); } } /** * 부모 디렉토리의 퍼미션·소유자·그룹을 상속하여 신규 디렉토리를 생성합니다. * * @param string $path 생성할 디렉토리 경로 * @return void */ protected static function createDirectoryInheritingParent(string $path): void { $parentDir = dirname($path); $parentExists = File::isDirectory($parentDir); $parentPerms = $parentExists ? (fileperms($parentDir) & 0777) : 0755; File::ensureDirectoryExists($path, $parentPerms, true); if ($parentExists) { static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir)); } } /** * 부모 디렉토리의 소유자·그룹을 대상 경로에 상속합니다. * * sudo 컨텍스트에서 root 가 만든 파일을 부모(보통 PHP-FPM owner) 로 정합화하기 위해 * 외부 호출처(예: `SettingsMigrator::writeJsonFile`) 가 직접 호출 가능하도록 public. * * @param string $path 소유권을 상속받을 파일 또는 디렉토리 * @return void */ public static function inheritOwnershipFromParent(string $path): void { $parentDir = dirname($path); if (! File::isDirectory($parentDir)) { return; } static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir)); } /** * 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지. * * @param string $path 대상 경로 * @param int|false $owner fileowner() 반환값 (false 허용) * @param int|false $group filegroup() 반환값 (false 허용) * @return void */ protected static function applyOwnership(string $path, int|false $owner, int|false $group): void { if ($owner !== false && function_exists('chown')) { @chown($path, $owner); } if ($group !== false && function_exists('chgrp')) { @chgrp($path, $group); } } /** * 웹서버(www-data 등) 계정의 소유자를 추정합니다. * * Laravel 표준상 웹서버가 쓰기 접근해야 하는 디렉토리(`storage/*`, `bootstrap/cache`) * 를 순회하여 base_path() 소유자와 **다른** 첫 소유자를 "웹서버 계정" 으로 판정한다. * 모든 후보가 base_path() 와 동일하면 대칭 구성으로 보고 base_path() 소유자를 반환. * * 사용 예: * - sudo 실행된 업데이트가 원본 스냅샷을 수집하지 못한 경우의 fallback * - 외부 프로세스(composer 등) 가 root 로 오염시킨 경로의 원본 추정 * * @return array{0: int|false, 1: int|false, 2: string} [owner, group, source] */ public static function inferWebServerOwnership(): array { $baseOwner = @fileowner(base_path()); $baseGroup = @filegroup(base_path()); if ($baseOwner === false) { return [false, false, 'none']; } $candidates = [ 'storage/logs', 'storage/framework/views', 'storage/framework/cache', 'storage/app', 'storage', 'bootstrap/cache', ]; foreach ($candidates as $candidate) { $path = base_path($candidate); if (! File::isDirectory($path)) { continue; } $owner = @fileowner($path); if ($owner !== false && $owner !== $baseOwner) { return [$owner, @filegroup($path), $candidate]; } } return [$baseOwner, $baseGroup, 'base_path (대칭 구성)']; } /** * 경로와 그 하위 항목의 소유자·그룹을 재귀적으로 복원합니다. * * 현재 소유자가 기준과 이미 일치하면 해당 항목은 스킵. symbolic link 는 링크 자체만 * 처리하고 대상은 따라가지 않는다. @chown/@chgrp suppress 로 권한 부족 / chown 미지원 * 환경에서도 silent fail. * * @param string $path 대상 경로 (파일 또는 디렉토리) * @param int $owner 기준 소유자 UID * @param int|false $group 기준 그룹 GID (false = 그룹 유지) * @return int 실제 소유권을 변경한 항목 수 */ public static function chownRecursive(string $path, int $owner, int|false $group): int { return self::chownRecursiveDetailed($path, $owner, $group)['changed']; } /** * `chownRecursive` 의 상세 결과 변형. 실패 경로를 누적하여 반환한다. * * 코어/확장 업데이트 흐름이 운영자에게 권한 정상화 실패 경로를 노출할 수 있도록 * 누적 결과를 구조화 반환한다. 실패 경로 수가 많을 때 로그 폭주를 막기 위해 * `failed_paths` 는 최대 50개로 잘라낸다 (전체 카운트는 `failed` 에 보존). * * `$respectPreservationMarker = true` 일 때 (트랙 2-A): 트리 순회 중 디렉토리에 * `.preserve-ownership` 파일이 발견되면 해당 서브트리 전체를 chown 비대상으로 skip. * `ModuleStorageDriver` / `PluginStorageDriver` 가 자동 작성하는 마커로 사용자 데이터 * (storage/app/{modules,plugins}/{id}/) 의 시드 시점 owner/perms 영구 보존. * * @param string $path 대상 경로 * @param int $owner 기준 소유자 UID * @param int|false $group 기준 그룹 GID (false = 그룹 유지) * @param bool $respectPreservationMarker `.preserve-ownership` 마커가 있는 서브트리 skip 여부 * @return array{changed:int, failed:int, failed_paths:array, supported:bool, skipped_subtrees:int} */ public static function chownRecursiveDetailed(string $path, int $owner, int|false $group, bool $respectPreservationMarker = false): array { if (! function_exists('chown')) { return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => false, 'skipped_subtrees' => 0]; } $report = ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'first_failure' => null, 'skipped_subtrees' => 0]; self::chownRecursiveInternal($path, $owner, $group, $report, $respectPreservationMarker); if ($report['failed'] > 0) { Log::warning('chownRecursive: 부분 실패', [ 'root' => $path, 'owner' => $owner, 'group' => $group, 'changed' => $report['changed'], 'failed' => $report['failed'], 'first_failure' => $report['first_failure'], ]); } // 마커 skip 카운트는 호출자(restoreOwnership 등) 의 종합 로그에 포함되므로 별도 info 미출력. return [ 'changed' => $report['changed'], 'failed' => $report['failed'], 'failed_paths' => array_slice($report['failed_paths'], 0, 50), 'supported' => true, 'skipped_subtrees' => $report['skipped_subtrees'], ]; } /** * 루트 디렉토리가 그룹 쓰기 권한을 가질 때, 하위 디렉토리·파일에 동일 권한을 승격합니다. * * 배경: sudo root 로 실행된 코어 업데이트가 umask 022 환경에서 신규 디렉토리/파일을 * `0755/0644` 로 생성한 뒤 `chownRecursive` 로 소유자만 원본(`jjh:www-data`) 으로 * 복원하면, 그룹(`www-data`) 에 쓰기 권한이 없는 비대칭이 영구 잔존한다. 결과적으로 * php-fpm(www-data) 이 `storage/framework/cache/...` 같은 경로에 쓰기 실패. * * 본 메서드는 다음 정책으로 비대칭을 해소한다: * - 루트가 `g+w` 면 하위 항목 중 `g-w` 인 디렉토리·파일을 `g+w` 로 승격 * - 루트가 `g-w` 면 no-op (운영자가 의도적으로 그룹 쓰기 차단한 정책 보존) * - 다른 비트(other, owner, sticky, setgid 등) 무변경 — `g+w` 만 OR * - symbolic link 는 링크 자체만 처리 (대상 미추적) * - 멱등 — 이미 정상인 항목은 changed 카운트에 포함 안 함 * - silent fail — 권한 부족·chmod 미지원 환경에서도 예외 미발생 * * @param string $root 대상 루트 (재귀 순회) * @return int 실제 chmod 한 항목 수 */ public static function syncGroupWritability(string $root): int { return self::syncGroupWritabilityDetailed($root)['changed']; } /** * `syncGroupWritability` 의 상세 결과 변형. 실패 경로를 누적하여 반환한다. * * `skipped` 는 루트가 g-w 정책 보존으로 no-op 되었거나 chmod 미지원 환경에서 true. * 코어/확장 업데이트가 운영자에게 권한 정상화 실패 경로를 즉시 노출할 때 사용. * * `$force=true` 시 루트가 g-w 라도 강제로 g+w 부여 후 하위 정상화. sudo root 가 0755 로 * 신규 디렉토리를 생성한 케이스(권한 정상화가 가장 필요한 시나리오) 에서 운영자 정책 보존 * 분기로 silent no-op 되던 결함을 차단할 때 사용. 일반 호출은 force=false (기존 동작 유지). * * @param string $root 대상 루트 * @param bool $force 루트 g-w 정책 강제 우회 * @return array{changed:int, failed:int, failed_paths:array, supported:bool, skipped:bool} */ public static function syncGroupWritabilityDetailed(string $root, bool $force = false): array { if (! function_exists('chmod') || ! is_dir($root)) { return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => function_exists('chmod'), 'skipped' => true]; } $rootPerms = @fileperms($root); if ($rootPerms === false) { return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => true, 'skipped' => true]; } if (($rootPerms & 0020) === 0) { if (! $force) { return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => true, 'skipped' => true]; } // force 모드: 루트에 g+w 강제 부여 → 이후 하위 정상화로 진행. sudo root 가 0755 로 // 신규 디렉토리를 생성한 시나리오에서 운영자 정책 보존 분기로 silent no-op 되던 결함 차단. if (! @chmod($root, $rootPerms | 0020)) { return ['changed' => 0, 'failed' => 1, 'failed_paths' => [$root], 'supported' => true, 'skipped' => false]; } } $report = ['changed' => 0, 'failed' => 0, 'failed_paths' => []]; self::syncGroupWritabilityInternal($root, $report, true); if ($report['changed'] > 0) { Log::info('syncGroupWritability: 그룹 쓰기 권한 정상화', [ 'root' => $root, 'changed' => $report['changed'], ]); } if ($report['failed'] > 0) { Log::warning('syncGroupWritability: 부분 실패', [ 'root' => $root, 'changed' => $report['changed'], 'failed' => $report['failed'], 'first_failure' => $report['failed_paths'][0] ?? null, ]); } return [ 'changed' => $report['changed'], 'failed' => $report['failed'], 'failed_paths' => array_slice($report['failed_paths'], 0, 50), 'supported' => true, 'skipped' => false, ]; } /** * syncGroupWritability 내부 재귀. * * @param string $path 대상 경로 * @param array{changed:int} $report 집계 (참조) * @param bool $isRoot 루트 자체는 정책 판정용으로만 사용 (chmod 안 함) */ private static function syncGroupWritabilityInternal(string $path, array &$report, bool $isRoot = false): void { // symbolic link 는 대상 추적 금지 if (is_link($path)) { return; } if (! $isRoot) { $perms = @fileperms($path); if ($perms !== false && ($perms & 0020) === 0) { // g+w 만 추가, 다른 비트 무변경 if (@chmod($path, $perms | 0020)) { $report['changed']++; } else { if (! isset($report['failed'])) { $report['failed'] = 0; $report['failed_paths'] = []; } $report['failed']++; if (count($report['failed_paths']) < 50) { $report['failed_paths'][] = $path; } } } } if (! is_dir($path)) { return; } $items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS); foreach ($items as $item) { self::syncGroupWritabilityInternal($item->getPathname(), $report, false); } } /** * chownRecursive 의 내부 재귀 구현. 실패 카운터를 참조 전달로 집계한다. * * @param string $path 대상 경로 * @param int $owner 기준 소유자 UID * @param int|false $group 기준 그룹 GID * @param array{changed:int, failed:int, first_failure:string|null, skipped_subtrees:int} $report 집계 구조 (참조) * @param bool $respectPreservationMarker `.preserve-ownership` 마커가 있는 디렉토리 서브트리 skip 여부 */ private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report, bool $respectPreservationMarker = false): void { // 트랙 2-A — 디렉토리에 .preserve-ownership 마커가 있으면 서브트리 전체 skip (자기 자신 + 하위) // ModuleStorageDriver / PluginStorageDriver 가 자동 작성하는 마커로 사용자 데이터 영구 보존. if ($respectPreservationMarker && is_dir($path) && ! is_link($path)) { $markerPath = $path.DIRECTORY_SEPARATOR.'.preserve-ownership'; if (@file_exists($markerPath)) { $report['skipped_subtrees']++; return; // 자기 자신 + 하위 모두 chown 비대상 } } $currentOwner = @fileowner($path); if ($currentOwner !== false && $currentOwner !== $owner) { if (@chown($path, $owner)) { $report['changed']++; } else { if ($report['first_failure'] === null) { $report['first_failure'] = $path; Log::warning('chown 최초 실패', ['path' => $path, 'owner' => $owner]); } $report['failed']++; if (! isset($report['failed_paths'])) { $report['failed_paths'] = []; } if (count($report['failed_paths']) < 50) { $report['failed_paths'][] = $path; } } } // chgrp 는 owner 일치 여부와 무관하게 별도 판정 (이전: chown 분기 안에 있어 owner 일치 시 chgrp 도 스킵되던 결함). // 운영자 환경에서 lang-packs 가 base_path owner 와 동일하지만 그룹은 root 등 다른 그룹으로 잔존하는 케이스에서 // 그룹 변경이 영구히 누락되던 silent fail 차단. if ($group !== false && function_exists('chgrp')) { $currentGroup = @filegroup($path); if ($currentGroup !== false && $currentGroup !== $group) { @chgrp($path, $group); } } if (! is_dir($path) || is_link($path)) { return; } $items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS); foreach ($items as $item) { self::chownRecursiveInternal($item->getPathname(), $owner, $group, $report, $respectPreservationMarker); } } }