Files
pnpm/pnpr
80b6225274 fix(sbom): normalize the repository URL published in SBOMs (#14795)
* fix(sbom): normalize the repository URL published in SBOMs

`pnpm sbom` published the manifest's `repository` value verbatim in the
CycloneDX `externalReferences[].url` and the SPDX `homepage`. The npm
`owner/repo` shorthand is not an iri-reference, so SBOM consumers such as
Dependency-Track reject the document.

pnpm v11 and pnpm v12 now apply the same rule to every published
repository value:
- expand the `owner/repo` shorthand to the `git+https` GitHub URL that
  npm's hosted-git-info derives for it
- parse other values with the WHATWG URL parser and emit them in their
  normalized form, with any embedded `user:password` removed
- drop values that do not parse or are not the shorthand (scp-style
  remotes, emails, relative paths) instead of publishing them

Closes pnpm/pnpm#14773

* fix(sbom): drop a username-only authority and check shorthand segments

Review follow-ups to the repository normalization, applied to pnpm v11
and pnpm v12 alike.

A repository URL now loses its userinfo even when it carries no
password. GitHub and GitLab both accept a token in place of the whole
`user:password`, so the username alone can be the secret. An ssh remote
keeps its `git@`, which names the login the host is reached with rather
than a credential.

A shorthand's owner and repository now have to consist of the characters
the hosts allow: ASCII letters, digits, `-`, `_` and `.`. `owner/repo?%`
used to expand to `git+https://github.com/owner/repo?%.git`, where the
`.git` lands in the query and `%` is not a valid escape.

The `github:`, `gitlab:` and `bitbucket:` prefixed shorthands expand to
the URL npm's hosted-git-info derives for them, instead of being dropped
from the SBOM.

`bugs` and `repository` read their field and clear their credentials
through one pair of helpers, so the two can no longer drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

* refactor(sbom): normalize repository values with pnpm's hosted-git parser

Both stacks already ship an npm-compatible hosted-git parser: the Rust
`HostedGit` of `pnpm-resolving-git-resolver`, which `pnpm licenses`
already applies to this same manifest field, and the `hosted-git-info`
fork the TypeScript git resolver uses. The SBOM command now expands
shorthands through them instead of through a parser of its own, so its
output is the URL npm derives.

What that changes, in both pnpm v11 and pnpm v12:

- `gitlab:group/subgroup/project` keeps every namespace segment, a
  committish survives as the URL's fragment, and an scp-style remote
  such as `git@github.com:foo/bar.git` becomes the https URL of the
  same repository instead of being dropped.
- A value counts as a URL when it parses and has a host, so
  `https:/github.com/foo/bar.git` is completed rather than dropped,
  while `github:owner/repo` still reaches the shorthand parser and
  `mailto:` and `file:` values, which name no repository a consumer can
  reach, are dropped.
- A shorthand that names no owner (`github:repo`) is dropped: the URL
  derived from it has an empty owner segment.

The ssh exception to credential stripping now names the `ssh` and
`git+ssh` schemes instead of accepting any scheme whose name ends in
`ssh`, so the token in `not-ssh://token@host/repo` is removed.

A probe of forty repository values confirms the two stacks agree on
every one, malformed percent escapes included. Both suites assert that
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

* test(sbom): pin how both versions treat a gist repository

The ownerless-shorthand guard also keeps out `gist:<id>`, which pnpm
v11's parser expands and pnpm v12's does not know at all. Dropping it on
both sides is what keeps the two versions publishing the same SBOM, and
a gist named by its URL is published like any other URL. Both suites now
assert that pair, so a later change cannot quietly expand the shorthand
in one version alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

* style(sbom): cut the prose to what the code cannot say

The comments had grown into a second description of the implementation:
doc comments enumerating branches the function body and its tests
already spell out, rationale copied from the code into the tests that
exercise it, and e2e test docs restating their own names.

What survives is the part a reader cannot derive: why `CycloneDX` makes a
raw manifest value unpublishable, why an ownerless shorthand and the
gist form are dropped, why a host decides whether a value is a URL, what
parsing buys beyond validation, and why an ssh login is not a credential.
Each sits once per stack, on the function that decides it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

* fix(sbom): drop a URL whose percent escapes are incomplete

The WHATWG parser keeps a `%` that begins no `%XX` escape exactly as the
manifest wrote it, so `https://example.com/%zz` survived normalization
and reached the CycloneDX and SPDX documents. An iri-reference admits no
such thing, which leaves the SBOM open to the rejection this change
exists to prevent.

Both versions now check the serialized URL before publishing it, on the
path `repository` and `bugs` share.

The changeset also becomes one idea per sentence, as the guide asks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

* test(sbom): pin the committish a hosted parser decodes

Both parsers decode a shorthand's committish, so `owner/repo#release%251`
derives a URL ending in a stray `%1`. Dropping it is the point of the
escape check, not a casualty of it: the alternative is publishing an
invalid iri-reference. The two versions were measured to agree, and the
case now sits beside the other incomplete escapes in both suites.

The helper's doc loses the sentence that restated its name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

* refactor(sbom): read the percent escape off the front of the segment

Splitting on `%` and inspecting the two bytes that follow says the same
thing as indexing from each match, without the index arithmetic that
invites a question about the final byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PjkYZtr2fTKD4wu3w1GCz4

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 11:35:27 +02:00
..
2026-09-12 12:01:04 +02:00