From 487283b33ad2559f76a78cb52bb23ebdd39014c4 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Wed, 8 Jul 2026 13:50:52 +0900 Subject: [PATCH] =?UTF-8?q?fix(core):=20core:update=20=EC=A6=9D=EB=B6=84?= =?UTF-8?q?=20=EB=AA=A8=EB=93=9C=EA=B0=80=20=5Fbundled=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20protected=20=EC=98=A4=EB=B2=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit targets 에 명시된 {modules,plugins,templates,lang-packs}/_bundled 가 상위 protected(modules 등)에 걸려 3-way apply 목록에서 제외되던 결함 수정. targets 가 protected 보다 더 구체적이면 오버라이드해 통과시켜, 코어 업데이트로 번들 확장 갱신 파일(composer.json·vendor-bundle.json 등)이 정상 반영되도록 한다. 자동 발견 폴백의 방어 및 prune 롤백 경로는 불변. 아울러 이슈 템플릿의 사용법 문의 링크를 Discussions 에서 sir.kr Q&A 로 전환. --- .github/ISSUE_TEMPLATE/config.yml | 6 +- CHANGELOG.md | 2 +- app/Extension/Helpers/CoreBackupHelper.php | 67 ++++++++++--- docs/backend/core-update-system.md | 5 +- .../CoreBackupHelperApplyListTest.php | 96 +++++++++++++++++++ tests/Unit/Services/CoreUpdateServiceTest.php | 52 ++++++++++ .../core-update-active-dir-preservation.yaml | 10 ++ 7 files changed, 220 insertions(+), 18 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 94e0db64..3440e7b5 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - - name: 질문·사용법 문의 (Discussions) - url: https://github.com/gnuboard/g7/discussions - about: 버그가 아닌 사용법 질문·아이디어 논의는 Discussions 를 이용해 주세요. + - name: 질문·사용법 문의 (sir.kr Q&A) + url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7 + about: 버그가 아닌 사용법 질문·사용법 문의는 sir.kr Q&A 를 이용해 주세요. - name: 보안 취약점 신고 url: https://github.com/gnuboard/g7/security/advisories/new about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요. diff --git a/CHANGELOG.md b/CHANGELOG.md index 468e7d14..59e6c423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ ### Changed -- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.) +- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 새 버전에 함께 담긴 기본 제공 확장(모듈·플러그인·템플릿·언어팩)의 갱신 파일은 이 과정에서도 정상적으로 최신 내용으로 반영되며, 사용자가 확장 폴더에 직접 추가해 둔 항목은 그대로 보존됩니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.) - 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다. - 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다. - 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다. diff --git a/app/Extension/Helpers/CoreBackupHelper.php b/app/Extension/Helpers/CoreBackupHelper.php index 204bd42e..dfcecb6d 100644 --- a/app/Extension/Helpers/CoreBackupHelper.php +++ b/app/Extension/Helpers/CoreBackupHelper.php @@ -238,6 +238,7 @@ class CoreBackupHelper $newDirs = []; $protectedSet = self::normalizeProtectedSet($protectedPaths); + $allowedTargetSet = self::normalizeProtectedSet($targets); $excludeSet = array_values(array_filter(array_map('trim', $excludes))); foreach ($targets as $target) { @@ -246,8 +247,9 @@ class CoreBackupHelper continue; } - // target 자체가 보호 경로면 스킵 - if (self::isWithinProtectedPath($target, $protectedSet)) { + // target 자체가 보호 경로면 스킵 (단, targets 에 더 구체적으로 명시된 + // 경로는 상위 protected 를 오버라이드 — _bundled 갱신 허용) + if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) { continue; } @@ -284,7 +286,7 @@ class CoreBackupHelper if (self::matchesExcludes($relative, $excludeSet)) { continue; } - if (self::isWithinProtectedPath($relative, $protectedSet)) { + if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) { continue; } @@ -369,7 +371,7 @@ class CoreBackupHelper * @param array $protectedPaths 보호 경로 목록 (목록에서 제외) * @param array $excludes 제외 패턴 목록 (예: ['node_modules', '.git']) * @return array{apply:array, added_count:int, changed_count:int, has_symlink:bool} - * apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화) + * apply = 적용 대상 상대경로 목록(정렬됨, 슬래시 정규화) */ public static function computeApplyList( string $backupPath, @@ -383,6 +385,7 @@ class CoreBackupHelper $hasSymlink = false; $protectedSet = self::normalizeProtectedSet($protectedPaths); + $allowedTargetSet = self::normalizeProtectedSet($targets); $excludeSet = array_values(array_filter(array_map('trim', $excludes))); foreach ($targets as $target) { @@ -391,7 +394,7 @@ class CoreBackupHelper continue; } - if (self::isWithinProtectedPath($target, $protectedSet)) { + if (self::isWithinProtectedPath($target, $protectedSet, $allowedTargetSet)) { continue; } @@ -433,7 +436,7 @@ class CoreBackupHelper if (self::matchesExcludes($relative, $excludeSet)) { continue; } - if (self::isWithinProtectedPath($relative, $protectedSet)) { + if (self::isWithinProtectedPath($relative, $protectedSet, $allowedTargetSet)) { continue; } @@ -661,23 +664,61 @@ class CoreBackupHelper /** * 상대 경로가 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): bool + 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) { - return true; - } - if (str_starts_with($relative, $p.'/')) { - return true; + if ($relative === $p || str_starts_with($relative, $p.'/')) { + $len = substr_count($p, '/') + 1; + if ($len > $matchedProtectedLen) { + $matchedProtectedLen = $len; + } } } - return false; + 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; } /** diff --git a/docs/backend/core-update-system.md b/docs/backend/core-update-system.md index 7f663cc8..0debc90d 100644 --- a/docs/backend/core-update-system.md +++ b/docs/backend/core-update-system.md @@ -193,8 +193,11 @@ v접두사 자동 감지 (resolveGithubArchiveUrl): · base 없음 → added / size·md5 다름 → changed / 동일 → 제외(스킵) · size 선필터 후 size 동일할 때만 md5 (mtime 비교 안 함 — _pending 은 추출 시각) · symlink / excludes / protected_paths 하위 → 목록 제외 + · 단, targets 에 더 구체적으로 명시된 경로는 상위 protected 를 오버라이드(아래 주석) ``` +> **protected_paths 오버라이드 (공개 #64 / 내부 #452)**: `protected_paths` 에는 확장 부모(`modules`·`plugins`·`templates`·`lang-packs`)가 포함되지만, `targets` 에는 `{domain}/_bundled` 가 명시된다. 3-way 산출(`computeApplyList`)과 신규 파일 manifest(`writeNewFilesManifest`)는 "targets 에 더 구체적(하위)으로 명시된 경로가 상위 protected 를 오버라이드"하도록 판정한다. 이로써 코어 배포본에 포함된 번들 확장의 갱신 파일(`_bundled/{id}/composer.json`·`vendor-bundle.json` 등)이 코어 업데이트로 정상 반영된다. 오버라이드는 target 이 protected 보다 **더 깊을 때만** 적용되므로, `storage`(target) == `storage`(protected) 같은 동일 경로는 여전히 제외된다. 확장 부모를 protected 에 둔 원래 의도(자동 발견 폴백이 활성 서브디렉토리 `modules/sirsoft-*` 를 삭제하는 #347 방어)는 그대로 유지된다 — 자동 발견 폴백은 targets 순회가 아니므로 오버라이드 영향을 받지 않는다. + ### Step 7: 파일 적용 ```text @@ -662,7 +665,7 @@ config('app.version') = env('APP_VERSION', 'config/app.php 기본값') |--------|---------|------| | `createBackup()` | `(?Closure $onProgress): string` | CoreBackupHelper로 백업 생성 | | `restoreFromBackup()` | `(string $backupPath, ?Closure $onProgress): void` | 백업에서 파일 복원 | -| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`) | +| `CoreBackupHelper::computeApplyList()` | `(string $backupPath, string $sourcePath, array $targets, array $protectedPaths, array $excludes): array` | 3-way 판정으로 증분 적용 대상(added/changed) 산출 (`apply`, `added_count`, `changed_count`, `has_symlink`). targets 에 명시된 `{domain}/_bundled` 는 상위 protected(`modules` 등)를 오버라이드해 목록에 포함 (공개 #64 / 내부 #452) | ### 적용 및 설치 diff --git a/tests/Unit/Extension/CoreBackupHelperApplyListTest.php b/tests/Unit/Extension/CoreBackupHelperApplyListTest.php index 11b702a5..918f111a 100644 --- a/tests/Unit/Extension/CoreBackupHelperApplyListTest.php +++ b/tests/Unit/Extension/CoreBackupHelperApplyListTest.php @@ -163,6 +163,102 @@ class CoreBackupHelperApplyListTest extends TestCase $this->assertSame([], $result['apply']); } + /** + * targets 에 명시된 `{domain}/_bundled` 경로는 상위 protected(`{domain}`)보다 + * 우선하여 apply 목록에 포함됩니다 (공개 #64 / 내부 #452 회귀). + * + * 배경: `config/app.php` 의 update.targets 에는 `modules/_bundled`, + * `plugins/_bundled`, `templates/_bundled`, `lang-packs/_bundled` 가 있고, + * protected_paths 에는 부모 `modules`, `plugins`, `templates`, `lang-packs` 가 + * 있다. protected 는 자동 발견 폴백이 활성 서브디렉토리(`modules/sirsoft-*`)를 + * 통째로 삭제하는 회귀(#347)를 막기 위한 것인데, 정상 증분 흐름의 apply 산출에서 + * `{domain}/_bundled/...` 파일이 `{domain}/` 접두사에 걸려 통째로 제외되면 + * 코어 업데이트 시 번들 확장의 새 파일(composer.json·vendor-bundle.*)이 + * 반영되지 않는다. targets 에 더 구체적으로 명시된 경로는 상위 protected 를 + * 오버라이드해야 한다. 4종 확장(모듈/플러그인/템플릿/언어팩) 전수 검증. + * + * @dataProvider bundledDomainProvider + */ + public function test_bundled_target_overrides_parent_protected_path(string $domain): void + { + $bundledTarget = "{$domain}/_bundled"; + + // theirs 에만 존재하는 신규 번들 파일 (added) + $newRel = "{$bundledTarget}/vendor-ext/composer.json"; + $newAbs = $this->theirsPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $newRel); + File::ensureDirectoryExists(dirname($newAbs)); + File::put($newAbs, '{"version":"1.0.1"}'); + + // base·theirs 양쪽에 있지만 내용이 다른 번들 파일 (changed) + $this->writeBoth( + "{$bundledTarget}/vendor-ext/vendor-bundle.json", + '{"composer_json_sha256":"old"}', + '{"composer_json_sha256":"new-value-differs"}', + ); + + // 활성 서브디렉토리(=targets 밖, protected 로 보존되어야 함) + $activeRel = "{$domain}/vendor-ext-active"; + File::ensureDirectoryExists($this->theirsPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $activeRel)); + File::put( + $this->theirsPath.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, "{$activeRel}/active.php"), + 'active', + ); + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + [$bundledTarget], // targets: _bundled 만 명시 + [$domain, 'storage', 'vendor'], // protected: 부모 도메인 포함 (실제 config 반영) + ['node_modules', '.git'], + ); + + // _bundled 하위 신규/변경 파일은 apply 목록에 포함되어야 한다 + $this->assertContains("{$bundledTarget}/vendor-ext/composer.json", $result['apply']); + $this->assertContains("{$bundledTarget}/vendor-ext/vendor-bundle.json", $result['apply']); + $this->assertSame(1, $result['added_count']); + $this->assertSame(1, $result['changed_count']); + + // 활성 서브디렉토리(targets 밖)는 순회 대상이 아니므로 목록에 없어야 한다 + $this->assertNotContains("{$activeRel}/active.php", $result['apply']); + } + + /** + * 4종 번들 확장 도메인 (config/app.php 의 targets/protected_paths 실제 조합). + * + * @return array + */ + public static function bundledDomainProvider(): array + { + return [ + 'modules' => ['modules'], + 'plugins' => ['plugins'], + 'templates' => ['templates'], + 'lang-packs' => ['lang-packs'], + ]; + } + + /** + * target 과 protected 가 정확히 동일한 경로면 여전히 제외됩니다 (기존 방어 유지). + * + * `_bundled` 오버라이드는 "targets 가 protected 보다 더 구체적(하위)일 때"만 + * 적용된다. target == protected (예: storage) 는 제외가 정상. + */ + public function test_target_equal_to_protected_is_still_excluded(): void + { + File::ensureDirectoryExists($this->theirsPath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'); + File::put($this->theirsPath.DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'runtime.txt', 'data'); + + $result = CoreBackupHelper::computeApplyList( + $this->basePath, + $this->theirsPath, + ['storage'], + ['storage'], + [], + ); + + $this->assertSame([], $result['apply']); + } + /** * added 와 changed 가 섞여 있을 때 apply 목록이 정렬되어 둘 다 포함합니다. */ diff --git a/tests/Unit/Services/CoreUpdateServiceTest.php b/tests/Unit/Services/CoreUpdateServiceTest.php index 5cf780d0..4a55bc20 100644 --- a/tests/Unit/Services/CoreUpdateServiceTest.php +++ b/tests/Unit/Services/CoreUpdateServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Services; +use App\Extension\Helpers\CoreBackupHelper; use App\Extension\Helpers\FilePermissionHelper; use App\Services\CoreUpdateService; use Illuminate\Support\Facades\Artisan; @@ -1185,6 +1186,57 @@ MD; } } + /** + * end-to-end 회귀 (공개 #64 / 내부 #452): 코어 업데이트가 번들 확장의 변경된 + * `_bundled` 파일을 실제로 반영합니다. + * + * computeApplyList(3-way 산출) → applyUpdate(적용) 전 체인을 실제 config 조합 + * (protected 에 `modules`, targets 에 `modules/_bundled`)으로 검증한다. + * protected 필터가 `_bundled` 갱신을 삼키던 결함으로 인해, 코어 배포본에 + * 새 번들(vendor-bundle.json/composer.json 갱신)이 포함돼도 활성 서버의 + * `_bundled` 가 갱신되지 않아 이후 `module:update` 무결성 검증이 실패하던 + * 회귀를 차단한다. + */ + public function test_incremental_apply_reflects_changed_bundled_extension_file(): void + { + [$source, $fakeBase, $restore] = $this->prepareApplyUpdateEnv(['modules/_bundled']); + + try { + // 실제 config 조합 재현: protected 에 부모 도메인 포함 + config(['app.update.protected_paths' => ['.env', 'storage', 'vendor', 'modules', 'plugins', 'templates', 'lang-packs']]); + + $rel = 'modules/_bundled/sirsoft-ecommerce/vendor-bundle.json'; + $relPlatform = str_replace('/', DIRECTORY_SEPARATOR, $rel); + + // base(구버전) = 옛 해시, source(신버전) = 새 해시 (코어가 변경한 번들 파일) + File::ensureDirectoryExists(dirname($fakeBase.DIRECTORY_SEPARATOR.$relPlatform)); + File::put($fakeBase.DIRECTORY_SEPARATOR.$relPlatform, '{"composer_json_sha256":"OLD"}'); + File::ensureDirectoryExists(dirname($source.DIRECTORY_SEPARATOR.$relPlatform)); + File::put($source.DIRECTORY_SEPARATOR.$relPlatform, '{"composer_json_sha256":"NEW-differs-in-length"}'); + + // 3-way 산출: base != theirs → changed 로 목록 포함되어야 한다 + $applyResult = CoreBackupHelper::computeApplyList( + $fakeBase, + $source, + config('app.update.targets'), + config('app.update.protected_paths'), + config('app.update.excludes'), + ); + $this->assertContains($rel, $applyResult['apply'], 'changed 된 _bundled 파일이 apply 목록에 포함되어야 한다'); + + // 적용: 활성(base) 파일이 신버전 내용으로 갱신되어야 한다 + $this->service->applyUpdate($source, null, prune: false, applyList: $applyResult['apply']); + + $this->assertSame( + '{"composer_json_sha256":"NEW-differs-in-length"}', + File::get($fakeBase.DIRECTORY_SEPARATOR.$relPlatform), + '코어 업데이트가 변경된 _bundled 번들 파일을 실제로 반영해야 한다', + ); + } finally { + $restore(); + } + } + /** * 증분 모드는 orphan(소스에 없는 대상 파일)을 삭제하지 않습니다 * (사용자가 추가한 신규 파일 보존). diff --git a/tests/scenarios/core-update-active-dir-preservation.yaml b/tests/scenarios/core-update-active-dir-preservation.yaml index 894b4298..19f5597d 100644 --- a/tests/scenarios/core-update-active-dir-preservation.yaml +++ b/tests/scenarios/core-update-active-dir-preservation.yaml @@ -45,6 +45,9 @@ axes: apply_mode: [incremental, prune, fallback_no_backup] # 파일 적용 방식 three_way_verdict: [unchanged_same, added_new, changed_size, changed_md5] # base↔theirs 판정 user_local_modification: [modified, untouched] # mine 이 base 와 다른지 (사용자 수정) + # targets 의 {domain}/_bundled 가 상위 protected({domain}) 를 오버라이드해 apply 되는지 (공개 #64 / 내부 #452) + bundled_apply_override: [override_applies, sibling_active_preserved, target_equals_protected_still_blocked] + protected_override_domain: [modules, plugins, templates, lang-packs] # 오버라이드 대상 4종 확장 exclusions: - { source_structure: empty_top_level, active_subdir_state: present, reason: "source 가 비었으면 자동 발견 폴백 자체가 트리거 안 됨 — 활성 서브디렉토리 보존 무조건 보장" } @@ -53,6 +56,8 @@ exclusions: - { apply_mode: prune, three_way_verdict: unchanged_same, reason: "prune 은 3-way 판정을 건너뛰고 전체 덮어쓰기 — three_way_verdict 무의미" } - { apply_mode: fallback_no_backup, three_way_verdict: unchanged_same, reason: "백업 부재 시 base 자체가 없어 3-way 불가 — 전체 덮어쓰기로 회귀" } - { apply_mode: incremental, user_local_modification: modified, three_way_verdict: changed_size, reason: "코어+사용자 동시 변경 — 코어 버전으로 갱신(§6 한계), 백업에서 복구 가능" } + - { apply_mode: prune, bundled_apply_override: override_applies, reason: "prune 은 3-way/protected 필터를 우회하고 전체 덮어쓰기 — 오버라이드 판정 자체가 무의미" } + - { bundled_apply_override: target_equals_protected_still_blocked, protected_override_domain: modules, reason: "target==protected 방어는 storage 같은 동일 경로 케이스로 검증 — 도메인 무관 (protected_override_domain 축과 직교 아님)" } effects: - applyDiscoveredTopLevelPaths_does_not_remove_active_subdirs_when_source_only_has_bundled @@ -91,6 +96,11 @@ effects: - test_apply_update_incremental_does_not_remove_orphans # 증분=삭제 안 함 - test_apply_update_prune_removes_orphans_and_overwrites_all # prune=전체 덮어쓰기+삭제 - test_apply_update_null_apply_list_falls_back_to_full_overwrite # 백업 부재 fallback + # targets 의 {domain}/_bundled 가 상위 protected 를 오버라이드해 코어 업데이트로 번들 확장 파일이 갱신됨 (공개 #64 / 내부 #452). + # effect 명 = 실제 테스트 메서드명 (CoreBackupHelperApplyListTest / CoreUpdateServiceTest). + - test_bundled_target_overrides_parent_protected_path # 4종 확장 전수 (dataProvider) + - test_target_equal_to_protected_is_still_excluded # target==protected 는 여전히 제외 (방어 유지) + - test_incremental_apply_reflects_changed_bundled_extension_file # computeApplyList→applyUpdate end-to-end 반영 test_files: - tests/Unit/Services/CoreUpdateServiceTest.php