Skip to content

Add support for strict publish validation of case insensitive display name matches - #2268

Open
netomi wants to merge 14 commits into
mainfrom
display-name-publish-check
Open

netomi wants to merge 14 commits into
mainfrom
display-name-publish-check

Conversation

@netomi

@netomi netomi commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Split out of #2081 (the publish-time enforcement half). Adds a new publish-time check that compares an extension's display name, case- and whitespace-insensitively, against every other publisher's active extensions, and rejects the publish on an exact match:

  • A brand-new extension is rejected outright if its display name exactly matches an existing one outside the publisher's own namespaces.
  • A new version that renames its extension onto an existing display name is rejected the same way; a version that keeps its extension's current display name is not re-checked (grandfathering existing duplicate display names already in the registry).
  • Skipped entirely when the instance mirrors another registry, since a mirror has to end up with what its upstream has.

Backed by a new case/whitespace-normalized index on extension_version.display_name so the check stays cheap at scale.

The other half of #2081 — the admin dashboard for moderating the existing (separate, unenforced) NAME_SQUATTING similarity check — is split out as #2267, since the two are independent (no shared code beyond two non-overlapping additions to RepositoryService).

Test plan

  • ./gradlew compileJava compileTestJava spotlessCheck passes
  • ./gradlew test --tests PublishExtensionVersionHandlerTest passes (the Postgres-backed ExtensionJooqRepositoryTest couldn't be run here — no Docker daemon in this environment)

🤖 Generated with Claude Code

autumnfound and others added 4 commits September 29, 2026 11:47
…isplay name matches

To best accomadate the current infrastructure, a new check was added to the publish workflow. This check will compare the display name of new extensions and new versions to existing data. For new extensions, exact matches will be blocked from upload.

In the case of versions, extensions whose name changes to conflict will be blocked, but existing names will not be blocked. This carve out was added to address any existing conflict of display name without potentially blocking a legitimate version from being submitted.

This grandfathered clause makes the assumption that the name squatting results will be monitored and addressed to get to a known good state. Once we get to a clean state, the grandfathered clause will effectively work as a standard exact match gate without any notable performance impact.

Assisted-by: Claude Opus 5
Rebasing onto main revealed a second collision: main has since added
V1_75, and the version used by "fix: Update migration file name to
resolve runtime conflict" (V1_74) collided with
V1_74__PersonalAccessToken_Column_Defaults.sql, also added by main
since this branch diverged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PublishExtensionVersionHandler.java called token.getUser() and
referenced a user variable that don't exist on main's
AuthenticatedUser/UserData split; use userData / au.userData() like
the rest of the class. Updated the test file's mocks to match
(LoggedInAuthentication instead of a raw PersonalAccessToken).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
checkPublishPreconditions read ExtensionProcessor#getDisplayName(),
which falls back to the extension name, while createExtensionVersion
read the metadata's display name, which is null in that case. A package
declaring no <DisplayName> was therefore held up against its extension
name, and only on the paths that scan: every version bump of such an
extension looked like a rename onto that name, locking the publisher out
permanently if any other extension displayed it.

Split the accessor into getDeclaredDisplayName(), authoritative and null
when the package declares none, and getDisplayNameOrExtensionName() for
the scan record that wants something readable. Fold the gate into one
checkDisplayNameAdoption method that every call site goes through.

Assisted-by: Claude Opus 5
@autumnfound

Copy link
Copy Markdown
Contributor

@netomi the update I just pushed was the fix for #2081 (comment), which is the only code review item for this branch from the previous PR.

@autumnfound
autumnfound requested review from cstamas and a balanced review from Copilot September 30, 2026 20:28
@autumnfound

Copy link
Copy Markdown
Contributor

Also, I can't add you to the PR as a reviewer, I'm assuming because you opened the PR.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Unicode whitespace bypasses normalization, and asynchronous activation leaves a concurrency window for duplicate names.

Review effort: Balanced
Findings: 3 High severity

Open (3)
What changed in this PR

Adds publish-time validation preventing cross-publisher display-name collisions while preserving mirrors and existing duplicates.

Changes:

  • Adds normalized display-name lookup and database index.
  • Enforces collision checks for new extensions and renames.
  • Adds processor accessors and comprehensive tests.
File Description
RepositoryServiceSmokeTest.java Covers the repository facade query.
ExtensionJooqRepositoryTest.java Tests normalized collision lookup.
RegistryAPITest.java Updates handler construction.
PublishExtensionVersionHandlerTest.java Tests enforcement and exemptions.
ExtensionProcessorTest.java Tests declared and fallback names.
V1_76__ExtensionVersion_DisplayName_Index.sql Adds the normalized lookup index.
ExtensionScanService.java Uses the display-label fallback.
RepositoryService.java Exposes the collision lookup.
ExtensionJooqRepository.java Implements active display-name lookup.
PublishExtensionVersionHandler.java Enforces publish-time validation.
ExtensionProcessor.java Separates declared and fallback names.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The display name conflict check trimmed only U+0020, so padding a name
with any other space separator the validator admits (U+00A0, U+2000,
U+3000, ...) bypassed it. Java, the query and the V1_76 index expression
now strip the same set, defined once in DisplayNameUtil.

The query also bound ev_match.active as a parameter, which a generic plan
cannot match to the partial index's WHERE active. It is now rendered bare.

A test prepares the rendered SQL under a forced generic plan and asserts
the plan uses extension_version_display_name_idx.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@autumnfound

Copy link
Copy Markdown
Contributor

The change I pushed addresses the changes around whitespace and has an additional funky test that makes sure that the index with the whitespace calculations are used when that lookup is done, as it could become a seq scan if not handled properly.

When discussing options w/ the AI, it raised that the Asynchronous activation allows duplicate normalized names issue has a decision we have to make and I wanted to check in with the team before making it. The best option we have is to add checks when we reactivate an extension that locks during reactivation to stop in-flight collisions and ensure we don't continue propagating the current issue. The decision here is what do we want to do with extensions that fail this check during reactivation?

Since these can be legitimate extensions that were squatted, we'll need to figure out some form of redress for it as well.

Keep both the LONG_LIVED rename and the display name lookup in
RepositoryServiceSmokeTest. Renumber the display name index migration to
V1_81, since main now has its own V1_76 through V1_80.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Scan-gated publications can bypass the check before asynchronous activation, and the required changelog entry is missing.

1 open finding
3 resolved since last review

🧠 Review effort: Balanced

@netomi

netomi commented Oct 14, 2026

Copy link
Copy Markdown
Contributor Author

I think this check would be better as a PublishCheck than inside createExtensionVersion.

mirrorVersion and the user publish path both go through doPublish -> createExtensionVersion, which is why the check needs mirrorEnabled and a second copy in checkPublishPreconditions. Mirroring never goes through PublishCheckRunner, so a PublishCheck bean (e.g. DISPLAY_NAME_CONFLICT) only runs on user publication, and:

  • the whole check becomes configurable (enabled / enforced / required), like NAME_SQUATTING, instead of an always-on rule;
  • it does not interfere with mirroring, and mirrorEnabled and the duplicated call site go away;
  • its results are recorded in the scan audit trail, next to the other publish checks.

Things to handle:

  • PublishCheck.Context only has the scan, which carries the fallback label (the extension name when no display name is declared). The declared display name would need to be added to ExtensionScan, so a package without a display name is not compared by its extension name.
  • The rename case needs the extension's current latest version, which can be looked up by namespace and extension name.
  • Like NAME_SQUATTING, it only runs when scanning is enabled.

The V1_81 index, the lookup query and DisplayNameUtil would stay as they are.

autumnfound and others added 2 commits October 28, 2026 13:28
publish check

Per review, the check runs as a PublishCheck instead of inside
createExtensionVersion. Mirroring never goes through PublishCheckRunner,
so the mirrorEnabled flag and the duplicate call in
checkPublishPreconditions are gone, and results land in the scan audit
trail next to the other publish checks.

The scan now stores the package's declared display name
(extension_scan.declared_display_name, V1_82) so a package without one
is not compared under its extension name.

Decisions to note:
- ovsx.scanning.display-name-conflict.{enabled,enforced,required} all
  default to true. This can be revisited before merge.
- Like every publish check, it only runs when ovsx.scanning.enabled is
  true (default false), so instances without scanning no longer run it.

Also sets published_by on the ExtensionJooqRepositoryTest fixture, which
the published_by_id NOT NULL constraint from main requires.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Martin Lowe <martin.lowe@eclipse-foundation.org>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Duplicate Flyway versions block application startup, while an activation race and platform-insensitive lookups undermine display-name conflict enforcement.

5 open issues
1 resolved since last review
2 files not reviewed
  • server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/ExtensionScan.java: Generated file
  • server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/records/ExtensionScanRecord.java: Generated file

Review effort: Balanced

Comment on lines +101 to +103
var conflict = repositories.findActiveExtensionByDisplayName(displayName, excludedNamespaces);
if (conflict == null) {
return Result.pass();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reactivation is out of scope. reactivateExtensions doesn't run the display name check. It only relies on scan status, so a version quarantined for a display name conflict stays inactive unless an admin allows it.

Versions that were active before are trusted when they come back. That leaves one known gap. If a publisher's extensions are inactive, for example after their agreement lapses, another publisher can take the display name in the meantime. When the first publisher reactivates, both extensions are active under the same name. We'll handle this in the upcoming rework for similar and duplicate names, which will need to deal with existing pairs anyway.

@netomi let me know if this aligns or if I should approach this another way.

autumnfound and others added 4 commits October 28, 2026 14:27
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Martin Lowe <mtlowe@live.ca>
Add condition to filter by target platform in latest query

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Martin Lowe <mtlowe@live.ca>
Two publishers could both pass the DISPLAY_NAME_CONFLICT publish check for
the same name while neither version was active, then both activate after
scanning. Activation now repeats the check under a transaction-scoped
advisory lock on the normalized name, keeping the namespace exclusions and
the existing-name exemption. A version that loses is quarantined rather
than rejected, since the one that won may be the impersonator.

The admin Quarantined tab now lists versions quarantined by a check
(includeCheckQuarantines on /admin/scans and /counts), lets admins select
them, and warns on allow which extension already shows the name.
Reactivating previously active extensions is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants