__invoke('backup', $target); $destPath = $backupPath.DIRECTORY_SEPARATOR.$target; if (File::isDirectory($sourcePath)) { FilePermissionHelper::copyDirectory($sourcePath, $destPath, $onProgress, $excludes); } else { FilePermissionHelper::copyFile($sourcePath, $destPath); } } Log::info('코어 백업 생성 완료', ['path' => $backupPath]); return $backupPath; } /** * 백업에서 코어 파일을 선택적으로 복원합니다. * * 복원 시에는 FilePermissionHelper를 사용하여 퍼미션을 보존합니다. * 백업 파일을 복원하되 현재 파일의 퍼미션은 유지합니다. * 개별 target 복원 실패 시에도 나머지 target 복원을 계속 진행합니다. * * 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 복사 직후 신 버전 신규 파일을 * 정리하는 prune 단계를 추가 실행한다. manifest 부재 시 기존 overlay 복사만 수행 * (기존 동작 유지). * * @param string $backupPath 백업 디렉토리 경로 * @param array $targets 복원 대상 경로 목록 * @param \Closure|null $onProgress 진행 콜백 * @param array $excludes 제외할 디렉토리/파일 이름 목록 (예: ['node_modules', '.git']) * @return array 복원 실패한 target 목록 (빈 배열이면 전체 성공) */ public static function restoreFromBackup(string $backupPath, array $targets, ?\Closure $onProgress = null, array $excludes = []): array { $failedTargets = []; foreach ($targets as $target) { $src = $backupPath.DIRECTORY_SEPARATOR.$target; $dest = base_path($target); if (! File::exists($src) && ! File::isDirectory($src)) { continue; } try { $onProgress?->__invoke('restore', $target); if (File::isDirectory($src)) { FilePermissionHelper::copyDirectory($src, $dest, $onProgress, $excludes); } else { FilePermissionHelper::copyFile($src, $dest); } } catch (\Throwable $e) { Log::error("코어 백업 복원 실패 (계속 진행): {$target}", [ 'error' => $e->getMessage(), 'backup_path' => $backupPath, ]); $failedTargets[] = $target; } } // Manifest 가 있으면 신 버전 신규 파일 prune. 부재 시 silent skip (기존 동작). $manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST; if (File::exists($manifestPath)) { try { $protected = (array) config('app.update.protected_paths', []); $pruneResult = self::pruneNewFiles($backupPath, base_path(), $protected, $onProgress); Log::info('코어 자동 롤백 prune 완료', [ 'backup_path' => $backupPath, 'removed_files' => $pruneResult['removed_files'], 'removed_dirs' => $pruneResult['removed_dirs'], 'protected_count' => $pruneResult['protected_count'], 'symlink_skipped' => $pruneResult['symlink_skipped'], 'failed_count' => $pruneResult['failed_count'], ]); } catch (\Throwable $e) { Log::warning('코어 자동 롤백 prune 실패 (overlay 복사는 완료)', [ 'backup_path' => $backupPath, 'error' => $e->getMessage(), ]); } } if (empty($failedTargets)) { Log::info('코어 백업 복원 완료', ['backup_path' => $backupPath]); } else { Log::warning('코어 백업 부분 복원 완료', [ 'backup_path' => $backupPath, 'failed_targets' => $failedTargets, ]); } return $failedTargets; } /** * 백업을 삭제합니다. * * @param string $backupPath 백업 디렉토리 경로 */ public static function deleteBackup(string $backupPath): void { if (! File::isDirectory($backupPath)) { return; } File::deleteDirectory($backupPath); // Windows: 파일 핸들 잠금으로 빈 디렉토리가 남을 수 있음 — 재시도 if (File::isDirectory($backupPath)) { usleep(500_000); // 0.5초 대기 후 재시도 File::deleteDirectory($backupPath); } if (File::isDirectory($backupPath)) { Log::warning('코어 백업 삭제 불완전 (잔여 디렉토리 존재)', ['path' => $backupPath]); } else { Log::info('코어 백업 삭제 완료', ['path' => $backupPath]); } } /** * 모든 코어 백업 목록을 반환합니다. * * @return array 백업 목록 [{path, created_at, size}] */ public static function listBackups(): array { $backupsDir = storage_path('app/core_backups'); if (! File::isDirectory($backupsDir)) { return []; } $backups = []; foreach (File::directories($backupsDir) as $dir) { $backups[] = [ 'path' => $dir, 'name' => basename($dir), 'created_at' => date('Y-m-d H:i:s', filectime($dir)), ]; } return $backups; } /** * 신 버전이 추가하는 파일/디렉토리 목록을 백업 디렉토리에 manifest 로 기록합니다. * * 비교 기준: * - 백업 디렉토리 (`$backupPath/$target`) = 활성 디렉토리의 사전 스냅샷 (= 현재 디스크의 구버전) * - 소스 디렉토리 (`$sourcePath/$target`) = 신 버전 _pending 소스 * - 신규 항목 정의 = `_pending` 에는 존재 + 백업에는 없는 파일/디렉토리 * * 자동 롤백 시 `restoreFromBackup()` 이 본 manifest 를 참조하여 활성 디렉토리에서 * 정확히 그 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어 * 있으므로 manifest 에서 제외되어 보존된다. * * `protectedPaths` 와 `excludes` 하위는 manifest 에서 사전 제외 (방어 깊이). `pruneNewFiles` * 도 동일 가드를 재실행하므로 이중 방어. * * @param string $backupPath 백업 디렉토리 경로 (= 활성 사전 스냅샷) * @param string $sourcePath 소스 디렉토리 경로 (= _pending 신 버전) * @param array $targets 처리할 target 경로 목록 (`app.update.targets`) * @param array $protectedPaths 보호 경로 목록 (manifest 에서 제외) * @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git']) * @param string $fromVersion 시작 버전 (manifest 기록용) * @param string $toVersion 대상 버전 (manifest 기록용) * @return array{new_files_count:int, new_dirs_count:int} */ public static function writeNewFilesManifest( string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes, string $fromVersion, string $toVersion, ): array { $newFiles = []; $newDirs = []; $protectedSet = self::normalizeProtectedSet($protectedPaths); $allowedTargetSet = self::normalizeProtectedSet($targets); $excludeSet = array_values(array_filter(array_map('trim', $excludes))); foreach ($targets as $target) { $target = trim($target); if ($target === '') { continue; } // target 자체가 보호 경로면 스킵 (단, targets 에 더 구체적으로 명시된 // 경로는 상위 protected 를 오버라이드 — _bundled 갱신 허용) if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) { continue; } $sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target; if (! file_exists($sourceTargetPath)) { continue; } // 단일 파일 target if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) { $backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target; if (! file_exists($backupFilePath)) { $newFiles[] = self::normalizeRelative($target); } continue; } if (! is_dir($sourceTargetPath)) { continue; } // 디렉토리 target — 재귀 비교 $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, ); foreach ($iterator as $item) { /** @var SplFileInfo $item */ $absolute = $item->getPathname(); $relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/')); if (self::matchesExcludes($relative, $excludeSet)) { continue; } if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) { continue; } $backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative); if ($item->isDir() && ! $item->isLink()) { if (! is_dir($backupItemPath)) { $newDirs[] = $relative; } } elseif ($item->isFile()) { if (! file_exists($backupItemPath)) { $newFiles[] = $relative; } } // symlink 는 manifest 에 등재하지 않음 — prune 도 어차피 skip } } sort($newFiles, SORT_STRING); sort($newDirs, SORT_STRING); $manifest = [ 'version' => self::MANIFEST_SCHEMA_VERSION, 'created_at' => date('c'), 'from_version' => $fromVersion, 'to_version' => $toVersion, 'new_files' => $newFiles, 'new_dirs' => $newDirs, ]; File::ensureDirectoryExists($backupPath); $manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST; File::put($manifestPath, json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, )); Log::info('코어 신규 파일 manifest 작성 완료', [ 'manifest_path' => $manifestPath, 'new_files_count' => count($newFiles), 'new_dirs_count' => count($newDirs), 'from_version' => $fromVersion, 'to_version' => $toVersion, ]); return [ 'new_files_count' => count($newFiles), 'new_dirs_count' => count($newDirs), ]; } /** * 코어(신 버전)가 실제로 추가·변경한 파일 목록(적용 대상)을 3-way 비교로 산출합니다. * * `core:update` 기본(증분) 모드에서 `applyUpdate()` 가 참조하는 "적용 대상 집합"을 * 만든다. 코어가 건드리지 않은 파일(base == theirs)은 목록에서 제외되어, 활성 * 디렉토리에 남은 현재 상태(사용자 수정 포함 가능)를 그대로 보존한다. * * 3-way 판정: * - base = 구버전 원본 = 백업 스냅샷 (`$backupPath/$target`) * - theirs = 신 버전 = _pending 소스 (`$sourcePath/$target`) * - 규칙: * · base 없음 (theirs 에만 존재) → added (신규 파일 — 적용) * · base 있고 size 또는 md5 다름 → changed (코어가 변경 — 적용) * · base 있고 size·md5 동일 → 제외 (코어 미변경 — 스킵/보존) * * 성능: size 가 다르면 md5 를 계산하지 않는다(즉시 changed). size 가 같을 때만 * `md5_file`. mtime 은 비교하지 않는다 — `_pending` 은 방금 추출되어 mtime 이 * 전부 추출 시각이므로 항상 "다름"으로 오판한다. * * symlink 는 목록에서 제외한다 — 링크 자체는 `copyDirectory` 의 별도 링크 처리 * 경로가 담당하며, 증분 모드에서도 항상 재생성되어야 한다(target 추적 없이 동일 * 링크). 따라서 반환값 `is_partial` 이 true 여도 링크는 정상 반영된다. * * `writeNewFilesManifest()` 와 동일한 순회 구조·가드(`excludes`/`protectedPaths`)를 * 재사용한다. 두 산출을 각각 별도 순회하지만, 반환값은 in-memory 로 즉시 소비되어 * 파일화 비용이 없다(롤백용 `_new_files_manifest.json` 은 별도). * * @param string $backupPath 백업 디렉토리 경로 (= base, 활성 사전 스냅샷) * @param string $sourcePath 소스 디렉토리 경로 (= theirs, _pending 신 버전) * @param array $targets 처리할 target 경로 목록 (`app.update.targets`) * @param array $protectedPaths 보호 경로 목록 (목록에서 제외) * @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git']) * @return array{apply:array, added_count:int, changed_count:int, has_symlink:bool} * apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화) */ public static function computeApplyList( string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes, ): array { $added = []; $changed = []; $hasSymlink = false; $protectedSet = self::normalizeProtectedSet($protectedPaths); $allowedTargetSet = self::normalizeProtectedSet($targets); $excludeSet = array_values(array_filter(array_map('trim', $excludes))); foreach ($targets as $target) { $target = trim($target); if ($target === '') { continue; } if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) { continue; } $sourceTargetPath = $sourcePath.DIRECTORY_SEPARATOR.$target; if (! file_exists($sourceTargetPath)) { continue; } // 단일 파일 target if (is_file($sourceTargetPath) && ! is_link($sourceTargetPath)) { $rel = self::normalizeRelative($target); $backupFilePath = $backupPath.DIRECTORY_SEPARATOR.$target; self::classifyForApply($rel, $sourceTargetPath, $backupFilePath, $added, $changed); continue; } if (is_link($sourceTargetPath)) { $hasSymlink = true; continue; } if (! is_dir($sourceTargetPath)) { continue; } // 디렉토리 target — 재귀 비교 $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($sourceTargetPath, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST, ); foreach ($iterator as $item) { /** @var SplFileInfo $item */ $absolute = $item->getPathname(); $relative = self::normalizeRelative($target.'/'.ltrim(substr($absolute, strlen($sourceTargetPath)), DIRECTORY_SEPARATOR.'/')); if (self::matchesExcludes($relative, $excludeSet)) { continue; } if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) { continue; } // symlink 는 목록 제외 — 링크 처리 경로가 별도 담당 if ($item->isLink()) { $hasSymlink = true; continue; } // 디렉토리 자체는 목록에 담지 않는다 — 파일 단위 적용이 디렉토리를 // 필요 시 생성한다(copyDirectory 가 대상 디렉토리를 ensure). if (! $item->isFile()) { continue; } $backupItemPath = $backupPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $relative); self::classifyForApply($relative, $absolute, $backupItemPath, $added, $changed); } } $apply = array_values(array_unique(array_merge($added, $changed))); sort($apply, SORT_STRING); return [ 'apply' => $apply, 'added_count' => count($added), 'changed_count' => count($changed), 'has_symlink' => $hasSymlink, ]; } /** * 3-way 판정으로 파일 하나를 added/changed 로 분류하거나 제외합니다. * * base(백업) 부재 → added, size 다름 또는 md5 다름 → changed, 동일 → 제외. * * @param string $relative 슬래시 정규화된 상대 경로 (목록 등재값) * @param string $theirsAbsolute 신 버전(_pending) 파일 절대 경로 * @param string $baseAbsolute 백업(base) 파일 절대 경로 * @param array $added added 누적 배열 (참조) * @param array $changed changed 누적 배열 (참조) */ private static function classifyForApply( string $relative, string $theirsAbsolute, string $baseAbsolute, array &$added, array &$changed, ): void { if (! file_exists($baseAbsolute) || is_dir($baseAbsolute)) { $added[] = $relative; return; } // size 선필터 — 다르면 md5 생략하고 즉시 changed $theirsSize = @filesize($theirsAbsolute); $baseSize = @filesize($baseAbsolute); if ($theirsSize !== $baseSize) { $changed[] = $relative; return; } // size 동일 → 내용 비교 if (@md5_file($theirsAbsolute) !== @md5_file($baseAbsolute)) { $changed[] = $relative; } // 동일하면 제외 (코어 미변경 — 스킵) } /** * 백업 디렉토리의 manifest 를 로드해 활성 디렉토리에서 신 버전 신규 파일을 정리합니다. * * 안전 가드: * - symlink 는 manifest 에 있더라도 무조건 skip (`public/storage` 등 운영 데이터 보호) * - protected_paths 하위 항목은 prune 시점에 재검증되어 skip (이중 방어) * - manifest 부재/JSON 파싱 실패 시 noop + manifest_loaded:false 반환 * - 디렉토리는 깊이 역순으로 rmdir 시도. 빈 디렉토리만 제거 (사용자 파일이 있으면 유지) * - 개별 항목 삭제 실패는 fatal 이 아닌 warning + failed_count 누적 * * @param string $backupPath 백업 디렉토리 경로 (manifest 위치) * @param string $activePath 활성(= 정리 대상) 디렉토리 경로 * @param array $protectedPaths 보호 경로 목록 (이중 가드) * @param \Closure|null $onProgress 진행 콜백 (현재 미사용 — 호출 시그니처 호환용) * @return array{removed_files:int, removed_dirs:int, protected_count:int, symlink_skipped:int, failed_count:int, manifest_loaded:bool} */ public static function pruneNewFiles( string $backupPath, string $activePath, array $protectedPaths, ?\Closure $onProgress = null, ): array { $result = [ 'removed_files' => 0, 'removed_dirs' => 0, 'protected_count' => 0, 'symlink_skipped' => 0, 'failed_count' => 0, 'manifest_loaded' => false, ]; $manifestPath = $backupPath.DIRECTORY_SEPARATOR.self::NEW_FILES_MANIFEST; if (! File::exists($manifestPath)) { return $result; } $raw = @file_get_contents($manifestPath); if ($raw === false) { Log::warning('코어 prune: manifest 읽기 실패 — skip', ['path' => $manifestPath]); return $result; } $manifest = json_decode($raw, true); if (! is_array($manifest) || ! isset($manifest['new_files']) || ! isset($manifest['new_dirs'])) { Log::warning('코어 prune: manifest JSON 파싱 실패 — skip', ['path' => $manifestPath]); return $result; } $result['manifest_loaded'] = true; $protectedSet = self::normalizeProtectedSet($protectedPaths); // 신규 파일 정리 foreach ((array) $manifest['new_files'] as $rel) { if (! is_string($rel) || $rel === '') { continue; } $rel = self::normalizeRelative($rel); if (self::isWithinProtectedPath($rel, $protectedSet)) { $result['protected_count']++; continue; } $absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel); if (is_link($absolute)) { $result['symlink_skipped']++; continue; } if (! file_exists($absolute)) { continue; // 이미 부재 — skip } if (is_dir($absolute) && ! is_link($absolute)) { // 파일로 등재되었으나 실제는 디렉토리 — 안전 우선, skip continue; } if (@unlink($absolute)) { $result['removed_files']++; } else { $result['failed_count']++; Log::warning('코어 prune: 파일 삭제 실패', ['path' => $absolute]); } } // 신규 디렉토리 정리 — 깊이 역순으로 빈 디렉토리만 rmdir $dirs = array_values(array_filter((array) $manifest['new_dirs'], 'is_string')); usort($dirs, fn ($a, $b) => substr_count($b, '/') <=> substr_count($a, '/')); foreach ($dirs as $rel) { $rel = self::normalizeRelative($rel); if (self::isWithinProtectedPath($rel, $protectedSet)) { $result['protected_count']++; continue; } $absolute = $activePath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $rel); if (is_link($absolute)) { $result['symlink_skipped']++; continue; } if (! is_dir($absolute)) { continue; } // 빈 디렉토리만 rmdir — 사용자 파일이 남아있으면 유지 if (self::isEmptyDirectory($absolute)) { if (@rmdir($absolute)) { $result['removed_dirs']++; } else { $result['failed_count']++; Log::warning('코어 prune: 디렉토리 삭제 실패', ['path' => $absolute]); } } } return $result; } /** * protected_paths 배열을 정규화된 비교 집합으로 변환합니다. * * 슬래시 정규화 + 좌우 공백 제거 + 빈 항목 제거 + 최상위/하위 prefix 매칭에 사용. * * @param array $paths * @return array */ private static function normalizeProtectedSet(array $paths): array { $out = []; foreach ($paths as $p) { $p = trim((string) $p); if ($p === '') { continue; } $out[] = self::normalizeRelative($p); } return $out; } /** * 상대 경로가 protected_paths 목록의 어떤 항목 하위에 위치하는지 검사합니다. * * `$allowedTargetSet` 이 주어지면 "targets 에 명시된 더 구체적인 경로" 는 상위 * protected 를 오버라이드한다. 예: `protected_paths` 에 `modules` 가 있고 * `targets` 에 `modules/_bundled` 가 명시되어 있으면, `modules/_bundled/...` 파일은 * protected 로 차단되지 않고 apply 대상에 포함된다. * * 배경: `protected_paths` 의 확장 부모(`modules`/`plugins`/`templates`/`lang-packs`)는 * "자동 발견 폴백" 이 활성 서브디렉토리(`modules/sirsoft-*`)를 통째로 삭제하는 * 회귀(#347) 를 막기 위한 방어인데, 정상 증분 흐름에서는 `_bundled` 갱신까지 * 함께 막아 코어 업데이트로 번들 확장 파일이 반영되지 않는 결함(#452 / 공개 #64)을 * 유발했다. targets 에 명시된 경로가 protected 보다 더 구체적(하위)이면 예외 처리한다. * * @param string $relative 검사할 상대 경로 * @param array $protectedSet 정규화된 보호 경로 집합 * @param array $allowedTargetSet 정규화된 targets 집합 (protected 오버라이드 허용) */ private static function isWithinProtectedPath(string $relative, array $protectedSet, array $allowedTargetSet = []): bool { $relative = self::normalizeRelative($relative); // 가장 구체적으로 매칭되는 protected 경로 길이(세그먼트 수)를 찾는다. $matchedProtectedLen = -1; foreach ($protectedSet as $p) { if ($p === '') { continue; } if ($relative === $p || str_starts_with($relative, $p.'/')) { $len = substr_count($p, '/') + 1; if ($len > $matchedProtectedLen) { $matchedProtectedLen = $len; } } } if ($matchedProtectedLen === -1) { return false; } // protected 하위이지만, targets 에 더 구체적으로(= 더 깊게) 명시된 경로의 // 하위이면 오버라이드하여 통과(= 보호 해제). target 이 protected 와 같거나 // 더 얕으면 오버라이드하지 않는다(예: target `storage` == protected `storage`). foreach ($allowedTargetSet as $t) { if ($t === '') { continue; } $targetLen = substr_count($t, '/') + 1; if ($targetLen <= $matchedProtectedLen) { continue; } if ($relative === $t || str_starts_with($relative, $t.'/')) { return false; } } return true; } /** * 상대 경로가 excludes 패턴(이름 또는 슬래시 포함 경로) 에 매칭되는지 검사합니다. * * 단순 이름(슬래시 미포함) 은 경로의 어떤 세그먼트와도 매칭 — `node_modules` 처럼 * 깊이 무관 제외 패턴 의도와 일치. */ private static function matchesExcludes(string $relative, array $excludes): bool { $segments = explode('/', $relative); foreach ($excludes as $exclude) { if ($exclude === '') { continue; } if (str_contains($exclude, '/')) { if ($relative === $exclude || str_starts_with($relative, $exclude.'/')) { return true; } } else { if (in_array($exclude, $segments, true)) { return true; } } } return false; } /** * 경로 문자열을 슬래시 정규화 + 좌측 슬래시 제거. */ private static function normalizeRelative(string $path): string { $p = str_replace('\\', '/', $path); $p = ltrim($p, '/'); // 연속 슬래시 제거 while (str_contains($p, '//')) { $p = str_replace('//', '/', $p); } return $p; } /** * 디렉토리가 비어있는지 검사 (`.` `..` 제외). */ private static function isEmptyDirectory(string $path): bool { if (! is_dir($path)) { return true; } $entries = @scandir($path); if ($entries === false) { return false; } return count(array_diff($entries, ['.', '..'])) === 0; } }