fix(core): core:update 증분 모드가 _bundled 확장 파일을 반영하도록 protected 오버라이드

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 로 전환.
This commit is contained in:
HeuJung
2026-07-08 13:54:18 +09:00
parent 212ecb3577
commit 487283b33a
7 changed files with 220 additions and 18 deletions
+3 -3
View File
@@ -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 로 신고해 주세요.
+1 -1
View File
@@ -12,7 +12,7 @@
### Changed
- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.)
- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 새 버전에 함께 담긴 기본 제공 확장(모듈·플러그인·템플릿·언어팩)의 갱신 파일은 이 과정에서도 정상적으로 최신 내용으로 반영되며, 사용자가 확장 폴더에 직접 추가해 둔 항목은 그대로 보존됩니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.)
- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.
- 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.
- 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.
+54 -13
View File
@@ -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<int,string>, 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<int, string> $protectedSet 정규화된 보호 경로 집합
* @param array<int, string> $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;
}
/**
+4 -1
View File
@@ -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) |
### 적용 및 설치
@@ -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<string, array{string}>
*/
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 목록이 정렬되어 둘 다 포함합니다.
*/
@@ -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(소스에 없는 대상 파일)을 삭제하지 않습니다
* (사용자가 추가한 신규 파일 보존).
@@ -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