Skip to content

feat: support extra catalog index images for Extensions UI [RHIDP-12933] - #4655

Merged
openshift-merge-bot[bot] merged 5 commits into
redhat-developer:mainfrom
rm3l:RHIDP-12933--update-install-dynamic-plugins-to-parse-and-extract-multiple-catalog-index-images
Apr 22, 2026
Merged

feat: support extra catalog index images for Extensions UI [RHIDP-12933]#4655
openshift-merge-bot[bot] merged 5 commits into
redhat-developer:mainfrom
rm3l:RHIDP-12933--update-install-dynamic-plugins-to-parse-and-extract-multiple-catalog-index-images

Conversation

@rm3l

@rm3l rm3l commented Apr 21, 2026

Copy link
Copy Markdown
Member

Description

This PR introduces a new EXTRA_CATALOG_INDEX_IMAGES env var in the install-dynamic-plugins.py script, so that additional catalog index images can be listed alongside the primary CATALOG_INDEX_IMAGE.
This is a comma-separated list of images.

Each extra image's catalog entities are extracted to an isolated sub-directory in the /extensions extraction dir, making those plugins automatically discoverable in the Extensions UI.

Notes:

  • Only the existing CATALOG_INDEX_IMAGE is considered primary source for extracting and handling the dynamic-plugins.default.yaml (DPDY). Extra catalog images contribute catalog entities only, since there is no use case yet for supporting multiple DPDY files
  • Extra images are extracted under <extraction_dir>/extra/, which should prevent entries from accidentally overwriting the primary index's catalog entities
  • Supports both name=<image_ref> and plain image_ref formats: explicit names produce cleaner directory names (e.g., /extra/community/), while plain refs auto-derive names
  • Duplicate sub-directory names warn but don't error out; the later entry overwrites the earlier one, with a warning printed

Assisted-by: Claude

Which issue(s) does this PR fix

PR acceptance criteria

Please make sure that the following steps are complete:

  • GitHub Actions are completed and successful
  • Unit Tests are updated and passing
  • E2E Tests are updated and passing
  • Documentation is updated if necessary (requirement for new features)
  • Add a screenshot if the change is UX/UI related

How to test changes / Special notes to the reviewer

Try running the install-dynamic-plugins script locally after setting the EXTRA_CATALOG_INDEX_IMAGES env var.
You can also deploy the image from this PR with the EXTRA_CATALOG_INDEX_IMAGES env var in the install-dynamic-plugins container:

❯ EXTRA_CATALOG_INDEX_IMAGES=quay.io/rhdh/plugin-catalog-index:1.10,community=quay.io/rhdh/plugin-catalog-index:1.9.1,partner=quay.io/rhdh/plugin-catalog-index:1.9.3,community=quay.io/rhdh/plugin-catalog-index:1.9.4,quay.io/rhdh/plugin-catalog-index:1.10
Logs
======= Created lock file: /dynamic-plugins-root/install-dynamic-plugins.lock

======= Extracting catalog index from quay.io/rhdh/plugin-catalog-index:1.9
        ==> Copying catalog index image to local filesystem
        ==> Extracting catalog index layers
        ==> Extracting layer a9837d06aa6340c55fe90c8ba4f4310ec114d362a3b93da55a7ff75ac0d2d1b1
        ==> Successfully extracted dynamic-plugins.default.yaml from catalog index image
        ==> Extracting extensions catalog entities to /extensions
        ==> Successfully extracted extensions catalog entities from index image

======= Extracting extra catalog index 'quay.io_rhdh_plugin-catalog-index_1.10' from quay.io/rhdh/plugin-catalog-index:1.10
        ==> Copying extra catalog index image to local filesystem
        ==> Extracting extra catalog index layers
        ==> Extracting layer 641a777bf41068b766fd520fd20c4d17eb202389dccd00eb5a35b85bea0d78df
        ==> Extracting extensions catalog entities to /extensions/extra/quay.io_rhdh_plugin-catalog-index_1.10
        ==> Successfully extracted extensions catalog entities from extra index image to /extensions/extra/quay.io_rhdh_plugin-catalog-index_1.10

======= Extracting extra catalog index 'community' from quay.io/rhdh/plugin-catalog-index:1.9.1
        ==> Copying extra catalog index image to local filesystem
        ==> Extracting extra catalog index layers
        ==> Extracting layer 79f2b46a48b5b2dffec5466be8e06638708e473484fbde053482a591a71ffab0
        ==> Extracting extensions catalog entities to /extensions/extra/community
        ==> Successfully extracted extensions catalog entities from extra index image to /extensions/extra/community

======= Extracting extra catalog index 'partner' from quay.io/rhdh/plugin-catalog-index:1.9.3
        ==> Copying extra catalog index image to local filesystem
        ==> Extracting extra catalog index layers
        ==> Extracting layer b47a90f180ba8d747fbcdce9af2b34e16101ee47a8ca895d8184057ebfc5cde1
        ==> Extracting extensions catalog entities to /extensions/extra/partner
        ==> Successfully extracted extensions catalog entities from extra index image to /extensions/extra/partner

======= Extracting extra catalog index 'community' from quay.io/rhdh/plugin-catalog-index:1.9.4
        ==> WARNING: Subdirectory 'community' was already used by 'quay.io/rhdh/plugin-catalog-index:1.9.1'. The previous extraction will be overwritten.
        ==> Copying extra catalog index image to local filesystem
        ==> Extracting extra catalog index layers
        ==> Extracting layer a9837d06aa6340c55fe90c8ba4f4310ec114d362a3b93da55a7ff75ac0d2d1b1
        ==> Extracting extensions catalog entities to /extensions/extra/community
        ==> Successfully extracted extensions catalog entities from extra index image to /extensions/extra/community

======= Extracting extra catalog index 'quay.io_rhdh_plugin-catalog-index_1.10' from quay.io/rhdh/plugin-catalog-index:1.10
        ==> WARNING: Subdirectory 'quay.io_rhdh_plugin-catalog-index_1.10' was already used by 'quay.io/rhdh/plugin-catalog-index:1.10'. The previous extraction will be overwritten.
        ==> Copying extra catalog index image to local filesystem
        ==> Extracting extra catalog index layers
        ==> Extracting layer 641a777bf41068b766fd520fd20c4d17eb202389dccd00eb5a35b85bea0d78df
        ==> Extracting extensions catalog entities to /extensions/extra/quay.io_rhdh_plugin-catalog-index_1.10
        ==> Successfully extracted extensions catalog entities from extra index image to /extensions/extra/quay.io_rhdh_plugin-catalog-index_1.10

======= Replacing dynamic-plugins.default.yaml with catalog index: /dynamic-plugins-root/.catalog-index-temp/dynamic-plugins.default.yaml

======= Including dynamic plugins from /dynamic-plugins-root/.catalog-index-temp/dynamic-plugins.default.yaml

[...]

And in the /extensions dir:

❯ tree -L 2 /tmp/extensions
/extensions
├── catalog-entities
│   ├── collections
│   ├── packages
│   └── plugins
└── extra
    ├── community
    ├── partner
    └── quay.io_rhdh_plugin-catalog-index_1.10

❯ tree -L 2 /extensions/extra
/extensions/extra
├── community
│   └── catalog-entities
│       ├── collections
│       ├── packages
│       └── plugins
├── partner
│   └── catalog-entities
│       ├── collections
│       ├── packages
│       └── plugins
└── quay.io_rhdh_plugin-catalog-index_1.10
    └── catalog-entities
        ├── collections
        ├── packages
        └── plugins

@openshift-ci

openshift-ci Bot commented Apr 21, 2026

Copy link
Copy Markdown

Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with /test all

@github-actions

Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

Comment thread docs/dynamic-plugins/installing-plugins.md Outdated
Comment thread docs/dynamic-plugins/installing-plugins.md Outdated
Comment thread docs/dynamic-plugins/installing-plugins.md Outdated
Comment thread docs/dynamic-plugins/installing-plugins.md Outdated
Comment thread docs/dynamic-plugins/installing-plugins.md Outdated
@rm3l rm3l changed the title feat: update install-dynamic-plugins to parse and extract multiple catalog index images [RHIDP-12933] feat: update install-dynamic-plugins to support extra catalog index images [RHIDP-12933] Apr 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

…12933]

The Extensions UI currently only displays plugins from the primary
CATALOG_INDEX_IMAGE. Organizations that maintain plugins across
separate registries (e.g., community vs partner catalogs) have no way
to surface all of them without rebuilding a single monolithic index.

Introduce EXTRA_CATALOG_INDEX_IMAGES so that additional catalog index
images can be listed (comma-separated). Each image's catalog entities
are extracted to an isolated subdirectory under the extensions volume,
making those plugins visible in the Extensions UI without affecting
the primary index's dynamic-plugins.default.yaml.

Ref: https://issues.redhat.com/browse/RHIDP-12933

Assisted-by: Claude
@rm3l
rm3l force-pushed the RHIDP-12933--update-install-dynamic-plugins-to-parse-and-extract-multiple-catalog-index-images branch from 76e5126 to 2ea5ad5 Compare April 21, 2026 09:39
@rm3l rm3l changed the title feat: update install-dynamic-plugins to support extra catalog index images [RHIDP-12933] feat: support extra catalog index images for Extensions UI [RHIDP-12933] Apr 21, 2026
rm3l added 3 commits April 21, 2026 11:52
Prevent an extra catalog index entry like 'catalog-entities=<image>'
from overwriting the primary index's catalog entities by extracting
all extra images under a dedicated extra/ parent directory.

Ref: https://issues.redhat.com/browse/RHIDP-12933

Assisted-by: Claude
The overwrite warning was printed before the extraction header line,
making it look like it related to the previous entry. Move it into
extract_extra_catalog_index() so it appears right after the header,
clearly associating it with the entry being processed.

Ref: https://issues.redhat.com/browse/RHIDP-12933

Assisted-by: Claude
…to-parse-and-extract-multiple-catalog-index-images
@redhat-developer redhat-developer deleted a comment from github-actions Bot Apr 21, 2026
@redhat-developer redhat-developer deleted a comment from github-actions Bot Apr 21, 2026
@redhat-developer redhat-developer deleted a comment from github-actions Bot Apr 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

@rm3l
rm3l marked this pull request as ready for review April 21, 2026 13:06
@redhat-developer redhat-developer deleted a comment from openshift-ci Bot Apr 21, 2026
@rm3l

rm3l commented Apr 21, 2026

Copy link
Copy Markdown
Member Author

/test

@rm3l

rm3l commented Apr 21, 2026

Copy link
Copy Markdown
Member Author

/agentic_review

@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Apr 21, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (1)

Grey Divider


Action required

1. Unsafe extra dir name 🐞 Bug ⛨ Security
Description
EXTRA_CATALOG_INDEX_IMAGES explicit names are used as raw path segments, allowing values like '..'
or '/tmp' to escape the intended <CATALOG_ENTITIES_EXTRACT_DIR>/extra root and then be recursively
deleted/overwritten. This can cause unintended data loss on the container filesystem (via
shutil.rmtree) and write catalog entities outside the Extensions extraction directory.
Code

scripts/install-dynamic-plugins/install-dynamic-plugins.py[R1195-1207]

+        subdirectory_parent = os.path.join(catalog_entities_parent_dir, subdirectory)
+        print(f"\t==> Extracting extensions catalog entities to {subdirectory_parent}", flush=True)
+
+        extensions_dir_from_catalog_index = os.path.join(catalog_index_temp_dir, 'catalog-entities', 'extensions')
+        if not os.path.isdir(extensions_dir_from_catalog_index):
+            extensions_dir_from_catalog_index = os.path.join(catalog_index_temp_dir, 'catalog-entities', 'marketplace')
+
+        if os.path.isdir(extensions_dir_from_catalog_index):
+            os.makedirs(subdirectory_parent, exist_ok=True)
+            catalog_entities_dest = os.path.join(subdirectory_parent, 'catalog-entities')
+            if os.path.exists(catalog_entities_dest):
+                shutil.rmtree(catalog_entities_dest, ignore_errors=True, onerror=None)
+            shutil.copytree(extensions_dir_from_catalog_index, catalog_entities_dest, dirs_exist_ok=True)
Relevance

⭐⭐⭐ High

Unsanitized subdirectory can escape base and trigger rmtree/copytree outside intended dir; team
accepts hardening fixes.

PR-#3737
PR-#3970

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
parse_extra_catalog_index_images() returns the user-provided explicit name without any
validation/sanitization, and extract_extra_catalog_index() uses that subdirectory directly in
os.path.join() to form the destination path and then performs a recursive delete+copy. Because
os.path.join(base, subdirectory) will honor absolute paths (e.g., subdirectory='/tmp') and parent
directory segments (e.g., subdirectory='..'), this can escape the intended extraction directory and
apply rmtree()/copytree() outside of it.

scripts/install-dynamic-plugins/install-dynamic-plugins.py[1217-1247]
scripts/install-dynamic-plugins/install-dynamic-plugins.py[1148-1208]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`EXTRA_CATALOG_INDEX_IMAGES` supports `name=image_ref`, but the `name` is currently used as a raw filesystem path segment. This allows absolute paths (e.g. `/tmp`) or traversal (e.g. `..`) to escape `<CATALOG_ENTITIES_EXTRACT_DIR>/extra`, after which the code will recursively delete (`shutil.rmtree`) and overwrite (`shutil.copytree`) paths outside the intended extraction directory.

### Issue Context
- `parse_extra_catalog_index_images()` accepts explicit names and returns them unchanged.
- `extract_extra_catalog_index()` does `os.path.join(catalog_entities_parent_dir, subdirectory)` and then deletes/copies into `<that>/catalog-entities`.

### Fix Focus Areas
- scripts/install-dynamic-plugins/install-dynamic-plugins.py[1148-1208]
- scripts/install-dynamic-plugins/install-dynamic-plugins.py[1217-1247]

### Suggested fix
1. Reject or sanitize explicit `name` values:
  - Disallow empty names.
  - Disallow absolute paths.
  - Disallow any path separators (`/` and `\\`).
  - Disallow `.` and `..` segments.
  - Optionally restrict to a safe regex like `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`.
2. Add a defensive check in `extract_extra_catalog_index()`:
  - Resolve the computed destination path and ensure it is within `extra_parent_dir` before calling `rmtree()`/`copytree()`.
3. Add unit tests covering bad names such as `/tmp`, `..`, `a/../b`, and empty name (e.g. `=quay.io/img:1.0`) to ensure they error/skip safely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Advisory comments

2. Primary catalog not subdirectory 📎 Requirement gap ≡ Correctness
Description
The primary CATALOG_INDEX_IMAGE catalog entities are extracted directly into
CATALOG_ENTITIES_EXTRACT_DIR/catalog-entities rather than a dedicated per-catalog subdirectory
under /extensions. This does not meet the requirement that the primary and each extra catalog
index extract into separate /extensions/<catalog>/... directories for isolation.
Code

scripts/install-dynamic-plugins/install-dynamic-plugins.py[R1392-1394]

+    catalog_entities_parent_dir = os.environ.get("CATALOG_ENTITIES_EXTRACT_DIR", os.path.join(tempfile.gettempdir(), "extensions"))
    if catalog_index_image:
-        # default to a temporary directory if the env var is not set
-        catalog_entities_parent_dir = os.environ.get("CATALOG_ENTITIES_EXTRACT_DIR", os.path.join(tempfile.gettempdir(), "extensions"))
        catalog_index_default_file = extract_catalog_index(catalog_index_image, dynamic_plugins_root, catalog_entities_parent_dir)
Relevance

⭐ Low

Repo previously merged primary extraction directly into <extract_dir>/catalog-entities; per-catalog
subdir not required historically.

PR-#3970
PR-#3984

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 3 requires the primary and each extra index image to extract into separate
subdirectories under /extensions. The PR continues to call extract_catalog_index(...) with
catalog_entities_parent_dir and that function writes to `os.path.join(catalog_entities_parent_dir,
'catalog-entities'), meaning the primary index is not placed into its own /extensions/<name>/...`
subdirectory while extras are.

Extract each catalog index image into a separate subdirectory under /extensions
scripts/install-dynamic-plugins/install-dynamic-plugins.py[1392-1394]
scripts/install-dynamic-plugins/install-dynamic-plugins.py[1126-1136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Primary catalog entities are extracted into the root `CATALOG_ENTITIES_EXTRACT_DIR/catalog-entities` instead of a dedicated per-catalog subdirectory under `/extensions`, so the primary index is not isolated the same way as extra catalogs.

## Issue Context
Compliance requires that the primary and each extra catalog index image extract into separate subdirectories under `/extensions` (e.g., `/extensions/<catalog>/...`) to ensure isolation between catalogs.

## Fix Focus Areas
- scripts/install-dynamic-plugins/install-dynamic-plugins.py[1392-1405]
- scripts/install-dynamic-plugins/install-dynamic-plugins.py[1126-1139]
- docs/dynamic-plugins/installing-plugins.md[88-113]
- scripts/install-dynamic-plugins/test_install-dynamic-plugins.py[3432-3885]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@rm3l

rm3l commented Apr 21, 2026

Copy link
Copy Markdown
Member Author

/test e2e-ocp-helm

…to-parse-and-extract-multiple-catalog-index-images
@openshift-ci openshift-ci Bot removed the lgtm label Apr 22, 2026
@openshift-ci

openshift-ci Bot commented Apr 22, 2026

Copy link
Copy Markdown

New changes are detected. LGTM label has been removed.

@sonarqubecloud

Copy link
Copy Markdown

@rm3l rm3l added the lgtm label Apr 22, 2026
@rm3l

rm3l commented Apr 22, 2026

Copy link
Copy Markdown
Member Author

Re-applying lgtm label as it was previously approved. I've only merged main to retrigger the E2E tests.

@github-actions

Copy link
Copy Markdown
Contributor

Image was built and published successfully. It is available at:

@openshift-merge-bot
openshift-merge-bot Bot merged commit 10eb2c3 into redhat-developer:main Apr 22, 2026
15 checks passed
@rm3l
rm3l deleted the RHIDP-12933--update-install-dynamic-plugins-to-parse-and-extract-multiple-catalog-index-images branch April 22, 2026 13:00
gustavolira added a commit to gustavolira/rhdh that referenced this pull request May 26, 2026
… the TS implementation

Brings the TS port back in sync with three commits that landed on main while
this PR was open:

- redhat-developer#4576 (OCI disable pre-merge): pre-compute the set of OCI registries that
  will be effectively disabled after merging, then filter them out of every
  plugin list before the merge calls skopeo. Avoids wasted remote fetches
  for plugins the operator already disabled at a higher level. New helpers
  in src/merger.ts (preMergeOciDisabledState, filterDisabledOciPlugins) and
  src/oci-key.ts (tryParseOciRegistryAndPath). loadAllPlugins() in
  src/index.ts now reads every include file up front so the pre-merge pass
  can run before mergePlugin() touches the OCI cache.

- redhat-developer#4666 (MAX_ENTRY_SIZE bump): default from 20MB to 40MB.

- redhat-developer#4655 (EXTRA_CATALOG_INDEX_IMAGES): comma-separated catalog index images,
  each extracted into <CATALOG_ENTITIES_EXTRACT_DIR>/extra/<name>/
  catalog-entities. New helpers in src/catalog-index.ts
  (extractCatalogIndexLayers refactored out of extractCatalogIndex,
  extractExtraCatalogIndex, parseExtraCatalogIndexImages,
  imageRefToSubdirectory). New maybeExtractExtraCatalogIndexes() in
  src/index.ts wires it into runInstaller after the primary catalog index.

Tests: 27 new cases under __tests__/merger-pre-merge.test.ts (15) and
__tests__/extra-catalog-index.test.ts (12) covering the level-override
matrix, ambiguous-pathless detection, same-level duplicates, invalid OCI
strings, extensions/marketplace fallback, missing-entities warning, and
the duplicate-subdir overwrite warning ordering. Existing
tar-extract.test.ts + types.test.ts updated for the new 40MB default. All
155 vitest cases pass. Bundle rebuilt.
openshift-merge-bot Bot pushed a commit that referenced this pull request May 27, 2026
…#4574)

* feat(install-dynamic-plugins): port from Python to TypeScript/Node.js

* feat(install-dynamic-plugins): port main's Python script changes into the TS implementation

Brings the TS port back in sync with three commits that landed on main while
this PR was open:

- #4576 (OCI disable pre-merge): pre-compute the set of OCI registries that
  will be effectively disabled after merging, then filter them out of every
  plugin list before the merge calls skopeo. Avoids wasted remote fetches
  for plugins the operator already disabled at a higher level. New helpers
  in src/merger.ts (preMergeOciDisabledState, filterDisabledOciPlugins) and
  src/oci-key.ts (tryParseOciRegistryAndPath). loadAllPlugins() in
  src/index.ts now reads every include file up front so the pre-merge pass
  can run before mergePlugin() touches the OCI cache.

- #4666 (MAX_ENTRY_SIZE bump): default from 20MB to 40MB.

- #4655 (EXTRA_CATALOG_INDEX_IMAGES): comma-separated catalog index images,
  each extracted into <CATALOG_ENTITIES_EXTRACT_DIR>/extra/<name>/
  catalog-entities. New helpers in src/catalog-index.ts
  (extractCatalogIndexLayers refactored out of extractCatalogIndex,
  extractExtraCatalogIndex, parseExtraCatalogIndexImages,
  imageRefToSubdirectory). New maybeExtractExtraCatalogIndexes() in
  src/index.ts wires it into runInstaller after the primary catalog index.

Tests: 27 new cases under __tests__/merger-pre-merge.test.ts (15) and
__tests__/extra-catalog-index.test.ts (12) covering the level-override
matrix, ambiguous-pathless detection, same-level duplicates, invalid OCI
strings, extensions/marketplace fallback, missing-entities warning, and
the duplicate-subdir overwrite warning ordering. Existing
tar-extract.test.ts + types.test.ts updated for the new 40MB default. All
155 vitest cases pass. Bundle rebuilt.

* fix(install-dynamic-plugins): inline prototype-pollution guard for CodeQL

The shared Set.has() lookup wasn't recognised by CodeQL's
js/prototype-polluting-function rule as an exhaustive sanitizer, even
though Object.defineProperty already bypasses the __proto__ setter.
Replace it with the inlined string-literal pattern CodeQL accepts.

Closes CodeQL alert #116 on PR #4574 (safeSet at merger.ts:36).

* fix(install-dynamic-plugins): address SonarQube findings on PR #4574

- catalog-index.ts: invert the eq===-1 branch so the positive case
  comes first (Sonar S3923 'Unexpected negated condition').
- index.ts: drop five redundant type assertions on PluginSpec / Plugin
  / IncludePluginList — TS already narrows correctly thanks to
  PluginSpec being a structural subset of Plugin and Array of being
  assignable to ReadonlyArray.
- merger.ts:
  * Split preMergeOciDisabledState into top-level helpers
    (processOciEntry, recordEntryState, recordRegistryPath,
    validateAmbiguousPathless, effectiveRegistryDisabled,
    computeDisabledRegistries) so the orchestrator stays well under
    the cognitive-complexity ceiling.
  * Replace the nested ternary in the explicit-paths sort with
    String#localeCompare.
  * Replace 'bucket && bucket.size === 1' with optional-chain
    'bucket?.size === 1'.
  * Drop a stray NUL byte in the entry-key template that crept in
    via an early Edit and was making the file impossible to diff in
    a few tools.

155 vitest cases still pass; bundle rebuilt.

* fix(install-dynamic-plugins): validate EXTRA_CATALOG_INDEX_IMAGES subdir names

Qodo's persistent review flagged that the explicit 'name=<ref>' form of
EXTRA_CATALOG_INDEX_IMAGES does not validate 'name', so an operator
could accidentally (or maliciously) extract an extra catalog index
outside of '<entitiesDir>/extra/' by passing names containing '..' or
path separators. The auto-derived form is already safe because
imageRefToSubdirectory strips '/', ':', and '@'.

Add isSafeSubdirectoryName() that rejects empty names, '.', '..', and
any name containing '/' or '\\'. Parsing logs a warning and skips the
entry instead of crashing the installer, matching the existing 'empty
image reference' handling.

6 new vitest cases cover the rejected forms and document that the check
is character-based (so URL-encoded separators like '%2F' are accepted
verbatim rather than decoded). Bundle rebuilt (225.6 kB).

* refactor(install-dynamic-plugins): apply self-review improvements

Targeted cleanups identified in the in-conversation review. No behaviour
changes outside of the two defense-in-depth guards listed below.

Correctness/security:
- installer-npm.ts: validate that the filename emitted by 'npm pack
  --json' is flat (no '/', no '\\', no leading '..') before joining it
  to 'destination'. A registry returning '../evil.tgz' would otherwise
  let extraction escape.
- installer-oci.ts: replace 'pkg.split("!")' with an indexOf-based
  splitOciPackage helper so plugin paths containing a literal '!' are no
  longer silently truncated. Applied to both the install path and the
  Always-policy digest comparison.
- catalog-index.ts: re-validate 'subdirectory' inside
  extractExtraCatalogIndex itself instead of trusting all callers to go
  through parseExtraCatalogIndexImages. Five new vitest cases cover the
  rejected forms.
- index.ts: drop the existsSyncSafe wrapper and use Node's existsSync
  directly (the wrapper was a literal reimplementation that also
  conflated 'doesn't exist' with 'no read permission').
- image-cache.ts: surface a typed InstallException with the image ref
  when 'io.backstage.dynamic-packages' is unparseable, instead of
  letting JSON.parse crash the install.

Maintainability:
- types.ts: new effectivePullPolicy() helper centralises the ':latest!'
  fallback that was duplicated between installer-oci.ts and
  definitelyNoOp in index.ts.
- merger.ts: copyPluginFields now uses a for-of loop with safeSet
  (same prototype-pollution guard as deepMerge) instead of building an
  intermediate object via Object.fromEntries. effectiveRegistryDisabled
  drops the 'as string' cast via destructuring + undefined check.
  IncludePluginList downgraded from exported to internal.
- tar-extract.ts: comment the POSIX assumption around the
  pluginPathBoundary check; the trailing slash is unambiguous now.
- index.ts: destructure process.argv to remove the 'as string' cast.
- installer-npm.ts: extract isNpmPackJsonEntry() type guard so the
  install path stays linear.
- concurrency.ts: replace the magic-number worker caps (6/3) with
  MAX_OCI_WORKERS / MAX_NPM_WORKERS named constants.
- catalog-index.ts: simplify isSafeSubdirectoryName (the second clause
  was unreachable once '/' and '\\' were rejected upfront).

166 vitest cases (was 161; +5 new defense-in-depth tests). Bundle
rebuilt (226.0 kB).

* refactor(install-dynamic-plugins): polish merger.ts type assertions

- Generic 'safeSet<T extends object>' removes the awkward
  'dst as unknown as Record<string, unknown>' double-cast in
  copyPluginFields. The Object.defineProperty signature already accepts
  any object, so the constraint widening is enough.
- Drop the redundant 'as unknown' on parseYaml's return in
  mergePluginsFromFile — yaml v2's parse() is already typed unknown.

No behaviour changes. 166 vitest cases still pass.

* ci(install-dynamic-plugins): emit JUnit XML for Codecov test analytics

The 'Upload test results to Codecov' step at the monorepo level looks at
$RUNNER_TEMP/test-results/ for JUnit reports. Because this PR doesn't
touch any yarn workspace, 'yarn run test --affected' skips all
workspace tests and the directory ends up empty — codecov[bot] then
posts a 'JUnit XML file not found' warning on the PR.

Run vitest with the junit reporter so it writes its own report into
$RUNNER_TEMP/test-results/install-dynamic-plugins.junit.xml; the
existing test-results-action then finds it (alongside any workspace
reports) and the warning stops firing. No new upload step needed —
test-results-action already searches that directory.

Verified locally: 'JUNIT report written to /tmp/.../junit.xml' (166
testcases serialized).

* ci(install-dynamic-plugins): run vitest before Codecov test-results upload

The previous attempt at producing a JUnit report didn't take effect
because the step ordering put it AFTER the 'Upload test results to
Codecov' step. The Codecov log confirmed this:

  14:33:08  error - No JUnit XML reports found.
  14:33:16  JUNIT report written to .../install-dynamic-plugins.junit.xml

Move the install-dynamic-plugins test step before the monorepo 'Run
tests' so its JUnit report lands in $RUNNER_TEMP/test-results/ in time
for the existing upload step to pick it up. The bundle freshness check
stays where it was (after the uploads) so it doesn't delay the
upload-bearing path further.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants