Skip to content

Add Package URL (PURL) display to crate sidebar #11416

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Jun 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/components/crate-sidebar.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@
<div local-class="metadata">
<h2 local-class="heading">Metadata</h2>

<div local-class="purl" data-test-purl>
{{svg-jar "link"}}
<CopyButton
@copyText={{@version.purl}}
class="button-reset"
local-class="purl-copy-button"
>
<span local-class="purl-text">{{@version.purl}}</span>
<Tooltip local-class="purl-tooltip"><strong>Package URL:</strong> {{@version.purl}} <small>(click to copy)</small></Tooltip>
</CopyButton>
<a
href="https://github.com/package-url/purl-spec"
target="_blank"
rel="noopener noreferrer"
local-class="purl-help-link"
aria-label="Learn more"
>
{{svg-jar "circle-question"}}
<Tooltip @text="Learn more about Package URLs" />
</a>
</div>

<time
datetime={{date-format-iso @version.created_at}}
local-class="date"
Expand Down
12 changes: 12 additions & 0 deletions app/components/crate-sidebar.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { action } from '@ember/object';
import { service } from '@ember/service';
import Component from '@glimmer/component';

Expand All @@ -6,6 +7,7 @@ import { didCancel } from 'ember-concurrency';
import { simplifyUrl } from './crate-sidebar/link';

export default class CrateSidebar extends Component {
@service notifications;
@service playground;
@service sentry;

Expand Down Expand Up @@ -39,4 +41,14 @@ export default class CrateSidebar extends Component {
}
});
}

@action
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.notifications.success('Copied to clipboard!');
} catch {
this.notifications.error('Copy to clipboard failed!');
}
}
}
57 changes: 56 additions & 1 deletion app/components/crate-sidebar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
.msrv,
.edition,
.license,
.bytes {
.bytes,
.purl {
display: flex;
align-items: center;

Expand Down Expand Up @@ -52,6 +53,60 @@
font-variant-numeric: tabular-nums;
}

.purl {
align-items: flex-start;
}

.purl-copy-button {
text-align: left;
width: 100%;
min-width: 0;
cursor: pointer;

&:focus {
outline: 2px solid var(--yellow500);
outline-offset: 1px;
border-radius: var(--space-3xs);
}
}

.purl-text {
word-break: break-all;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}

.purl-tooltip {
word-break: break-all;

> small {
word-break: normal;
}
}

.purl-help-link {
color: unset;
margin-left: var(--space-2xs);
flex-shrink: 0;

&:hover {
color: unset;
}

&:focus {
outline: 2px solid var(--yellow500);
outline-offset: 1px;
border-radius: var(--space-3xs);
}

svg {
margin: 0;
}
}

.links {
> * + * {
margin-top: var(--space-m);
Expand Down
10 changes: 10 additions & 0 deletions app/models/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { alias } from 'macro-decorators';
import semverParse from 'semver/functions/parse';

import ajax from '../utils/ajax';
import { addRegistryUrl } from '../utils/purl';

const EIGHT_DAYS = 8 * 24 * 60 * 60 * 1000;

Expand Down Expand Up @@ -52,6 +53,15 @@ export default class Version extends Model {
return this.belongsTo('crate').id();
}

/**
* Returns the Package URL (PURL) for this version.
* @type {string}
*/
get purl() {
let basePurl = `pkg:cargo/${this.crateName}@${this.num}`;
return addRegistryUrl(basePurl);
}

get editionMsrv() {
if (this.edition === '2018') {
return '1.31.0';
Expand Down
22 changes: 22 additions & 0 deletions app/utils/purl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import window from 'ember-window-mock';

/**
* Adds a repository_url query parameter to a PURL string based on the host.
*
* @param {string} purl - The base PURL string (e.g., "pkg:cargo/[email protected]")
* @param {string} [host] - The host to use for repository URL. Defaults to current window location host.
* @returns {string} The PURL with repository_url parameter added, or unchanged if host is crates.io
*/
export function addRegistryUrl(purl) {
let host = window.location.host;

// Don't add repository_url for the main crates.io registry
if (host === 'crates.io') {
return purl;
}

// Add repository_url query parameter
const repositoryUrl = `https://${host}/`;
const separator = purl.includes('?') ? '&' : '?';
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels like this is redundant, since we can't ever generate a package URL that includes a ? right now?

(I guess, more broadly, I'm not quite clear on why this is separated from the basic PURL generation in the model.)

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not quite clear on why this is separated from the basic PURL generation in the model

I initially implemented the same property on the crate model too and wanted to DRY it up 😉

return `${purl}${separator}repository_url=${encodeURIComponent(repositoryUrl)}`;
}
35 changes: 35 additions & 0 deletions tests/models/version-test.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { module, test } from 'qunit';

import { calculateReleaseTracks } from '@crates-io/msw/utils/release-tracks';
import window from 'ember-window-mock';
import { setupWindowMock } from 'ember-window-mock/test-support';

import { setupTest } from 'crates-io/tests/helpers';
import setupMsw from 'crates-io/tests/helpers/setup-msw';

module('Model | Version', function (hooks) {
setupTest(hooks);
setupMsw(hooks);
setupWindowMock(hooks);

hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
Expand Down Expand Up @@ -345,4 +348,36 @@ module('Model | Version', function (hooks) {
assert.ok(version.published_by);
assert.strictEqual(version.published_by.name, 'JD');
});

module('purl', function () {
test('generates PURL for crates.io version', async function (assert) {
let { db, store } = this;

window.location = 'https://crates.io';

let crate = db.crate.create({ name: 'serde' });
db.version.create({ crate, num: '1.0.136' });

let crateRecord = await store.findRecord('crate', crate.name);
let versions = (await crateRecord.versions).slice();
let version = versions[0];

assert.strictEqual(version.purl, 'pkg:cargo/[email protected]');
});

test('generates PURL with registry URL for non-crates.io hosts', async function (assert) {
let { db, store } = this;

window.location = 'https://staging.crates.io';

let crate = db.crate.create({ name: 'test-crate' });
db.version.create({ crate, num: '2.5.0' });

let crateRecord = await store.findRecord('crate', crate.name);
let versions = (await crateRecord.versions).slice();
let version = versions[0];

assert.strictEqual(version.purl, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fstaging.crates.io%2F');
});
});
});
69 changes: 69 additions & 0 deletions tests/utils/purl-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { module, test } from 'qunit';

import window from 'ember-window-mock';
import { setupWindowMock } from 'ember-window-mock/test-support';

import { addRegistryUrl } from 'crates-io/utils/purl';

module('Utils | purl', function (hooks) {
setupWindowMock(hooks);

module('addRegistryUrl()', function () {
test('returns PURL unchanged for crates.io host', function (assert) {
window.location = 'https://crates.io';

let purl = 'pkg:cargo/[email protected]';
let result = addRegistryUrl(purl);

assert.strictEqual(result, purl);
});

test('adds repository_url parameter for non-crates.io hosts', function (assert) {
window.location = 'https://staging.crates.io';

let purl = 'pkg:cargo/[email protected]';
let result = addRegistryUrl(purl);

assert.strictEqual(result, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fstaging.crates.io%2F');
});

test('adds repository_url parameter for custom registry hosts', function (assert) {
window.location = 'https://my-registry.example.com';

let purl = 'pkg:cargo/[email protected]';
let result = addRegistryUrl(purl);

assert.strictEqual(result, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fmy-registry.example.com%2F');
});

test('appends repository_url parameter when PURL already has query parameters', function (assert) {
window.location = 'https://staging.crates.io';

let purl = 'pkg:cargo/[email protected]?arch=x86_64';
let result = addRegistryUrl(purl);

assert.strictEqual(result, 'pkg:cargo/[email protected]?arch=x86_64&repository_url=https%3A%2F%2Fstaging.crates.io%2F');
});

test('properly URL encodes the repository URL', function (assert) {
window.location = 'https://registry.example.com:8080';

let purl = 'pkg:cargo/[email protected]';
let result = addRegistryUrl(purl);

assert.strictEqual(result, 'pkg:cargo/[email protected]?repository_url=https%3A%2F%2Fregistry.example.com%3A8080%2F');
});

test('handles PURL with complex qualifiers', function (assert) {
window.location = 'https://private.registry.co';

let purl = 'pkg:cargo/[email protected]?os=linux&arch=amd64';
let result = addRegistryUrl(purl);

assert.strictEqual(
result,
'pkg:cargo/[email protected]?os=linux&arch=amd64&repository_url=https%3A%2F%2Fprivate.registry.co%2F',
);
});
});
});
Loading