351 lines
14 KiB
PHP
351 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Extension\Helpers;
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class FilePermissionHelper
|
|
{
|
|
/**
|
|
* 디렉토리를 재귀적으로 복사하면서 기존 파일/디렉토리의 퍼미션을 보존합니다.
|
|
*
|
|
* - 기존 디렉토리: 퍼미션/소유자/그룹 유지
|
|
* - 신규 디렉토리: 부모 디렉토리의 퍼미션/소유자/그룹 상속
|
|
* - 기존 파일: 퍼미션/소유자/그룹 유지한 채 내용만 교체
|
|
* - 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (퍼미션은 PHP 기본 umask)
|
|
* - removeOrphans=false: 소스에 없고 대상에만 있는 파일 유지 (사용자 추가 파일 보호)
|
|
* - removeOrphans=true: 소스에 없고 대상에만 있는 파일/디렉토리 삭제 (excludes 제외)
|
|
*
|
|
* 신규 항목의 소유권 상속은 sudo 로 실행된 업데이트 프로세스가 root 소유로 파일을
|
|
* 생성하는 것을 방지한다. vendor/ 처럼 cleanDirectory 후 재생성되는 디렉토리 구조
|
|
* 전체가 기존 부모(= vendor/) 의 소유권을 승계하도록 보장한다.
|
|
*
|
|
* @param string $source 소스 디렉토리 경로
|
|
* @param string $destination 대상 디렉토리 경로
|
|
* @param \Closure|null $onProgress 진행 콜백
|
|
* @param array $excludes 제외할 이름 또는 경로 목록 (예: ['node_modules', '.git', 'node_modules/test_dir'])
|
|
* @param string $relativePath 현재 상대 경로 (내부 재귀용)
|
|
* @param bool $removeOrphans 소스에 없는 대상 파일/디렉토리 삭제 여부
|
|
* @return void
|
|
*/
|
|
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false): void
|
|
{
|
|
if (! File::isDirectory($destination)) {
|
|
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
|
|
static::createDirectoryInheritingParent($destination);
|
|
}
|
|
// 기존 디렉토리: 퍼미션 건드리지 않음 (그대로 유지)
|
|
|
|
$items = new \FilesystemIterator($source, \FilesystemIterator::SKIP_DOTS);
|
|
|
|
foreach ($items as $item) {
|
|
$itemName = $item->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));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 부모 디렉토리의 소유자·그룹을 대상 경로에 상속합니다.
|
|
*
|
|
* @param string $path 소유권을 상속받을 파일 또는 디렉토리
|
|
* @return void
|
|
*/
|
|
protected 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
|
|
{
|
|
if (! function_exists('chown')) {
|
|
return 0;
|
|
}
|
|
|
|
// 재귀 전체 기간 동안 실패/성공을 집계하고 종료 시 요약 로그를 남긴다.
|
|
// 경로당 개별 로그는 재귀가 깊어지면 로그 폭주 유발 → 최초 실패 1건만 즉시 로깅.
|
|
$report = ['changed' => 0, 'failed' => 0, 'first_failure' => null];
|
|
self::chownRecursiveInternal($path, $owner, $group, $report);
|
|
|
|
if ($report['failed'] > 0) {
|
|
Log::warning('chownRecursive: 부분 실패', [
|
|
'root' => $path,
|
|
'owner' => $owner,
|
|
'group' => $group,
|
|
'changed' => $report['changed'],
|
|
'failed' => $report['failed'],
|
|
'first_failure' => $report['first_failure'],
|
|
]);
|
|
}
|
|
|
|
return $report['changed'];
|
|
}
|
|
|
|
/**
|
|
* chownRecursive 의 내부 재귀 구현. 실패 카운터를 참조 전달로 집계한다.
|
|
*
|
|
* @param string $path 대상 경로
|
|
* @param int $owner 기준 소유자 UID
|
|
* @param int|false $group 기준 그룹 GID
|
|
* @param array{changed:int, failed:int, first_failure:string|null} $report 집계 구조 (참조)
|
|
*/
|
|
private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report): void
|
|
{
|
|
$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 ($group !== false && function_exists('chgrp')) {
|
|
@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);
|
|
}
|
|
}
|
|
}
|