Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/install/dependency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ pub(crate) fn is_scp_like_path(dependency: &[u8]) -> bool {
///
/// This also checks for a github url that ends with ".tar.gz"
#[inline]
fn is_github_tarball_path(dependency: &[u8]) -> bool {
pub(crate) fn is_github_tarball_path(dependency: &[u8]) -> bool {
if is_tarball(dependency) {
return true;
}
Expand Down
4 changes: 3 additions & 1 deletion src/install/lockfile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3159,7 +3159,9 @@ impl Lockfile {
) else {
return false;
};
url == canonical_url.as_slice()
// Older yarn.lock migrations stored yarn's `#<sha1>` on the URL. A fragment is
// never sent to the registry, so it cannot change which tarball is downloaded.
Comment thread
robobun marked this conversation as resolved.
crate::yarn::Entry::url_without_hash(url) == canonical_url.as_slice()
}

fn declared_by_root_or_workspace(&self, alias: &[u8], resolution: &Resolution) -> bool {
Expand Down
66 changes: 37 additions & 29 deletions src/install/yarn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,13 @@ impl<'a> Entry<'a> {
}

pub(crate) fn is_git_dependency(version: &[u8]) -> bool {
if let Some(github_path) = version.strip_prefix(b"https://github.com/") {
// An archive download's `#` is yarn's tarball hash, not a commit.
return !dependency::is_github_tarball_path(Entry::url_without_hash(github_path));
}
version.starts_with(b"git+")
|| version.starts_with(b"git://")
|| version.starts_with(b"github:")
|| version.starts_with(b"https://github.com/")
}

pub(crate) fn is_npm_alias(version: &[u8]) -> bool {
Expand All @@ -143,6 +146,14 @@ impl<'a> Entry<'a> {
version.starts_with(b"https://") && version.ends_with(b".tgz")
}

/// yarn v1 writes tarball `resolved` fields as `<url>#<sha1 of the tarball>`.
pub(crate) fn url_without_hash(resolved: &[u8]) -> &[u8] {
match strings::index_of_char_usize(resolved, b'#') {
Some(hash_idx) => &resolved[..hash_idx],
None => resolved,
}
}

pub(crate) fn is_workspace_dependency(version: &[u8]) -> bool {
version.starts_with(b"workspace:") || version == b"*"
}
Expand Down Expand Up @@ -954,31 +965,26 @@ pub(crate) fn migrate_yarn_lockfile<'a>(

package_id_to_yarn_idx[package_id as usize] = yarn_idx;

let resolved_url: Option<&[u8]> = entry.resolved.as_deref().map(Entry::url_without_hash);

let name_to_use: &[u8] = 'blk: {
if entry.commit.is_some() && entry.git_repo_name.is_some() {
break 'blk entry.git_repo_name.as_deref().unwrap();
} else if let Some(resolved) = entry.resolved.as_deref() {
if is_direct_url_dep
|| Entry::is_remote_tarball(resolved)
|| resolved.ends_with(b".tgz")
} else if let (true, Some(resolved)) = (is_direct_url_dep, resolved_url) {
// https://registry.npmjs.org/package/-/package-version.tgz
if strings::index_of(resolved, b"registry.npmjs.org/").is_some()
|| strings::index_of(resolved, b"registry.yarnpkg.com/").is_some()
{
// https://registry.npmjs.org/package/-/package-version.tgz
if strings::index_of(resolved, b"registry.npmjs.org/").is_some()
|| strings::index_of(resolved, b"registry.yarnpkg.com/").is_some()
{
if let Some(separator_idx) = strings::index_of(resolved, b"/-/") {
if let Some(registry_idx) = strings::index_of(resolved, b"registry.") {
let after_registry = &resolved[registry_idx..];
if let Some(domain_slash) = strings::index_of(after_registry, b"/")
{
let package_start = registry_idx + domain_slash + 1;
let extracted_name = &resolved[package_start..separator_idx];
break 'blk extracted_name;
}
if let Some(separator_idx) = strings::index_of(resolved, b"/-/") {
if let Some(registry_idx) = strings::index_of(resolved, b"registry.") {
let after_registry = &resolved[registry_idx..];
if let Some(domain_slash) = strings::index_of(after_registry, b"/") {
let package_start = registry_idx + domain_slash + 1;
let extracted_name = &resolved[package_start..separator_idx];
break 'blk extracted_name;
}
}
}
break 'blk base_name;
}
}
break 'blk base_name;
Expand Down Expand Up @@ -1048,29 +1054,31 @@ pub(crate) fn migrate_yarn_lockfile<'a>(
}
}
break 'blk Resolution::default();
} else if let Some(resolved) = entry.resolved.as_deref() {
} else if let Some(resolved) = resolved_url {
if is_direct_url_dep {
break 'blk Resolution::init(ResolutionValue::RemoteTarball(
sbuf!().append(resolved)?,
));
}

// Yarn v1 lockfiles legitimately contain entries without an integrity field
// (workspace deps, file:, codeload tarballs), so migration intentionally
// accepts off-registry tarball URLs without integrity instead of failing.
if Entry::is_remote_tarball(resolved) || resolved.ends_with(b".tgz") {
break 'blk Resolution::init(ResolutionValue::RemoteTarball(
sbuf!().append(resolved)?,
));
}

let version = sbuf!().append(entry.version)?;
let result =
Semver::Version::parse(version.sliced(this.buffers.string_bytes.as_slice()));
if !result.valid {
// Yarn v1 lockfiles legitimately contain entries without an integrity field
// (workspace deps, file:, codeload tarballs), so migration intentionally
// accepts off-registry tarball URLs without integrity instead of failing.
Comment thread
robobun marked this conversation as resolved.
if Entry::is_remote_tarball(resolved) || resolved.ends_with(b".tgz") {
break 'blk Resolution::init(ResolutionValue::RemoteTarball(
sbuf!().append(resolved)?,
));
}
break 'blk Resolution::default();
}

// `has_trusted_dependency` compares this URL with the canonical registry
// tarball URL, so it must be the bare URL a fresh install records: no
// `#sha1`, and no RemoteTarball just because the URL ends in `.tgz`.
Comment thread
robobun marked this conversation as resolved.
let is_default_registry = resolved.starts_with(b"https://registry.yarnpkg.com/")
|| resolved.starts_with(b"https://registry.npmjs.org/");

Expand Down
173 changes: 173 additions & 0 deletions test/cli/install/bun-install-lifecycle-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,179 @@ test.concurrent("default trusted dependencies require the canonical registry tar
expect(await exited).toBe(0);
});

describe("default trusted dependencies after yarn.lock migration", () => {
const electronIntegrity =
"sha512-GkuwCdn6o8Krsxb3DIIqYP+TAi8Y5jYUadmseZ6nR2op2k5ssdKRYo4JjYDGopa1ACrGAcQuWViz/+vX/WjYnA==";
// `all-lifecycle-scripts@1.0.0`: a different package with preinstall, install
// and postinstall scripts of its own, used to record a tarball that is not
// electron's under the default-trusted name `electron`.
const otherIntegrity =
"sha512-hgU56juWYnFOQ3byQuydEgugxd+iWvaWfaoGvly4k/AxehC3dhM6IhXoDc3K7b/n1mP/II8hGIjI+LmxXFNMlw==";

// Installs `electron@1.0.0` (on the default trusted list) from a yarn.lock
// that resolves it to `resolved`, and returns the migrated bun.lock.
async function installFromYarnLock(ctx: TestCtx, resolved: string, integrity: string) {
const { packageDir, packageJson, env } = ctx;
await Promise.all([
writeFile(
packageJson,
JSON.stringify({
name: "foo",
version: "1.0.0",
dependencies: {
"electron": "1.0.0",
},
}),
),
writeFile(
join(packageDir, "yarn.lock"),
`# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1


electron@1.0.0:
version "1.0.0"
resolved "${resolved}"
integrity ${integrity}
`,
),
]);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "pipe",
stdin: "ignore",
stderr: "pipe",
env,
});

const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(err).toContain("migrated lockfile from yarn.lock");
expect(err).not.toContain("error:");
expect(await exists(join(packageDir, "node_modules", "electron", "package.json"))).toBeTrue();
expect(exitCode).toBe(0);
return { out, lockfile: await file(join(packageDir, "bun.lock")).text() };
}

// yarn v1 writes the tarball's sha1 as a URL fragment after registry tarball
// URLs. Other producers of the format (bun's own `--yarn` output, registries
// without a shasum) omit it. Both shapes describe the canonical registry
// tarball for electron@1.0.0, so both must keep the default-trusted grant
// after migration, exactly like an install that never had a yarn.lock.
test.concurrent.each([
["with a sha1 fragment", "#f1b8bc2c23cd7e4f1500669dfaf8757578d2e391"],
["without a fragment", ""],
])("scripts run for the canonical registry tarball URL %s", async (_, fragment) => {
using ctx = await setupTest();
const canonicalUrl = `http://localhost:${verdaccio.port}/electron/-/electron-1.0.0.tgz`;

const { out, lockfile } = await installFromYarnLock(ctx, `${canonicalUrl}${fragment}`, electronIntegrity);

expect(out).not.toContain("Blocked");
expect(await exists(join(ctx.packageDir, "node_modules", "electron", "preinstall.txt"))).toBeTrue();
// The migrated entry is an npm package whose tarball URL is the canonical
// one for the configured registry, with yarn's fragment dropped.
expect(lockfile).toContain(`"electron": ["electron@1.0.0", "${canonicalUrl}", {}, "${electronIntegrity}"]`);
});

test.concurrent("scripts stay blocked when the yarn.lock points the name at another tarball", async () => {
using ctx = await setupTest();
// Same registry, but another package's tarball recorded under the
// default-trusted name. Migration must keep that URL, so the canonical URL
// check still denies the default grant.
const otherUrl = `http://localhost:${verdaccio.port}/all-lifecycle-scripts/-/all-lifecycle-scripts-1.0.0.tgz`;

const { lockfile } = await installFromYarnLock(
ctx,
`${otherUrl}#91cd0bd6a450b21db0078b9118c54bc0a27fccb7`,
otherIntegrity,
);

expect(lockfile).toContain(`"electron": ["electron@1.0.0", "${otherUrl}", {}, "${otherIntegrity}"]`);
const electronDir = join(ctx.packageDir, "node_modules", "electron");
expect(
await Promise.all([
exists(join(electronDir, "install.js")),
exists(join(electronDir, "preinstall.txt")),
exists(join(electronDir, "install.txt")),
exists(join(electronDir, "postinstall.txt")),
]),
).toEqual([true, false, false, false]);
});

// Earlier versions of the migrator wrote yarn's `#sha1` into bun.lock itself.
// Those lockfiles are still out there, so the canonical URL check has to look
// past the fragment (which never reaches the registry) while still rejecting
// a URL whose path is not electron's tarball.
test.concurrent.each([
[
"runs scripts for the canonical tarball",
"electron/-/electron-1.0.0.tgz#f1b8bc2c23cd7e4f1500669dfaf8757578d2e391",
electronIntegrity,
true,
],
[
"keeps blocking another tarball",
"all-lifecycle-scripts/-/all-lifecycle-scripts-1.0.0.tgz#91cd0bd6a450b21db0078b9118c54bc0a27fccb7",
otherIntegrity,
false,
],
])("a bun.lock already migrated with the #sha1 suffix %s", async (_, tarball, integrity, scriptsRun) => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
const url = `http://localhost:${verdaccio.port}/${tarball}`;

await Promise.all([
writeFile(
packageJson,
JSON.stringify({
name: "foo",
version: "1.0.0",
dependencies: {
"electron": "1.0.0",
},
}),
),
writeFile(
join(packageDir, "bun.lock"),
`{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "foo",
"dependencies": {
"electron": "1.0.0",
},
},
},
"packages": {
"electron": ["electron@1.0.0", "${url}", {}, "${integrity}"],
}
}
`,
),
]);

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: packageDir,
stdout: "pipe",
stdin: "ignore",
stderr: "pipe",
env,
});

const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]);
expect(err).not.toContain("error:");
const electronDir = join(packageDir, "node_modules", "electron");
expect(
await Promise.all([exists(join(electronDir, "package.json")), exists(join(electronDir, "preinstall.txt"))]),
).toEqual([true, scriptsRun]);
expect(exitCode).toBe(0);
});
});

test.concurrent("binary lockfile trusted dependency entries require an exact name match", async () => {
using ctx = await setupTest();
const { packageDir, packageJson, env } = ctx;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2935,7 +2935,7 @@ exports[`bun pm migrate for existing yarn.lock yarn-cli-repo: yarn-cli-repo 1`]

"class-utils/define-property/is-descriptor/is-data-descriptor/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ="],

"commitizen/inquirer/cli-cursor/restore-cursor/onetime": ["onetime@1.1.0", "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789", {}, "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k="],
"commitizen/inquirer/cli-cursor/restore-cursor/onetime": ["onetime@1.1.0", "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", {}, "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k="],

"eslint/inquirer/chalk/supports-color/has-flag": ["has-flag@3.0.0", "", {}, "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="],

Expand Down Expand Up @@ -3154,7 +3154,7 @@ exports[`bun pm migrate for existing yarn.lock yarn-stuff: yarn-stuff 1`] = `

"reg": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],

"remote": ["abbrev@https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8", {}],
"remote": ["abbrev@https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", {}],

"symlink": ["symlink@file:abbrev-link-target", {}],

Expand Down
Loading
Loading