Files
Gnuboard7/app/Extension/Helpers/FilePermissionHelper.php
T
2026-05-11 11:29:41 +09:00

538 lines
23 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));
}
}
/**
* 부모 디렉토리의 소유자·그룹을 대상 경로에 상속합니다.
*
* 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<int,string>, 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<int,string>, 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);
}
}
}