diff --git a/actions/dependency-cooldown/dist/index.mjs b/actions/dependency-cooldown/dist/index.mjs index 6af93f3..148e0d4 100644 --- a/actions/dependency-cooldown/dist/index.mjs +++ b/actions/dependency-cooldown/dist/index.mjs @@ -11235,8 +11235,19 @@ var NON_REGISTRY_PROTOCOLS = [ "git+", "github:", "http:", - "https:" + "https:", + "root:" ]; +var LOCAL_TARBALL_EXTENSIONS = [".tgz", ".tar.gz", ".tar"]; +function isLocalTarballSpecifier(specifier) { + if (specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/")) { + return true; + } + return LOCAL_TARBALL_EXTENSIONS.some((extension) => specifier.endsWith(extension)); +} +function isNpmRegistryTuple(entry) { + return typeof entry[1] === "string"; +} function splitDescriptor(descriptor) { const separator = descriptor.indexOf("@", 1); if (separator <= 0) { @@ -11283,6 +11294,10 @@ var bunLockfile = { uncheckable.push({ name, version: null, reason: `resolved via ${protocol}` }); continue; } + if (!isNpmRegistryTuple(entry) && isLocalTarballSpecifier(specifier)) { + uncheckable.push({ name, version: null, reason: "resolved via local tarball" }); + continue; + } refs.push({ registry: "npm", name, version: specifier }); } return { refs, uncheckable }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9ef88e5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "shared-workflows", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/dependency-cooldown/src/lockfiles/bun.ts b/packages/dependency-cooldown/src/lockfiles/bun.ts index 0c00435..401599d 100644 --- a/packages/dependency-cooldown/src/lockfiles/bun.ts +++ b/packages/dependency-cooldown/src/lockfiles/bun.ts @@ -7,6 +7,14 @@ import type { DependencyRef, LockfileFormat, ParsedLockfile } from "../types.js" * `"": ["@", registry, meta, integrity]`. Non-registry * dependencies use a descriptor such as `name@workspace:path` or * `name@git+https://...` instead of a semver version. + * + * Local tarballs are a separate resolution type: Bun writes `name@./file.tgz` + * with no `file:` prefix (folders use `file:`, remote tarball URLs use + * `http:`/`https:`). Distinguishing them from registry versions must use the + * tuple, not the specifier suffix: npm prereleases such as `1.2.3-release.tgz` + * are valid, and Bun still stores them as `[name@version, registry, meta, + * integrity]` with a string registry field. Local tarballs put the metadata + * object in that slot: `[name@tarball, meta]`. */ const NON_REGISTRY_PROTOCOLS = [ "workspace:", @@ -16,8 +24,23 @@ const NON_REGISTRY_PROTOCOLS = [ "github:", "http:", "https:", + "root:", ]; +const LOCAL_TARBALL_EXTENSIONS = [".tgz", ".tar.gz", ".tar"]; + +function isLocalTarballSpecifier(specifier: string): boolean { + if (specifier.startsWith("./") || specifier.startsWith("../") || specifier.startsWith("/")) { + return true; + } + return LOCAL_TARBALL_EXTENSIONS.some((extension) => specifier.endsWith(extension)); +} + +/** npm resolutions store a registry URL (or `""` for the default) at index 1. */ +function isNpmRegistryTuple(entry: unknown[]): boolean { + return typeof entry[1] === "string"; +} + function splitDescriptor(descriptor: string): { name: string; specifier: string } { // The separator is the first @ after index 0: a scoped name starts with @, // and a git specifier may itself contain one (git+ssh://git@host/...). @@ -73,6 +96,10 @@ export const bunLockfile: LockfileFormat = { uncheckable.push({ name, version: null, reason: `resolved via ${protocol}` }); continue; } + if (!isNpmRegistryTuple(entry) && isLocalTarballSpecifier(specifier)) { + uncheckable.push({ name, version: null, reason: "resolved via local tarball" }); + continue; + } refs.push({ registry: "npm", name, version: specifier }); } diff --git a/packages/dependency-cooldown/test/end-to-end.test.ts b/packages/dependency-cooldown/test/end-to-end.test.ts index 341c611..e14e59f 100644 --- a/packages/dependency-cooldown/test/end-to-end.test.ts +++ b/packages/dependency-cooldown/test/end-to-end.test.ts @@ -386,4 +386,34 @@ describe("audit mode", () => { expect(code).toBe(0); }); + + it("audits a bun.lock that pins a local tarball without querying the registry for it", async () => { + write( + "bun.lock", + `{ + "lockfileVersion": 1, + "packages": { + "left-pad": ["left-pad@1.3.0", "", {}, "sha512-bbbb"], + "human-readable-checksum": ["human-readable-checksum@./package/human-readable-checksum-0.3.0.tgz", {}] + } + }`, + ); + commit("base"); + + const inner = httpStub({}); + const http: HttpClient = { + async getJson(url) { + if (url === "https://registry.npmjs.org/human-readable-checksum") { + throw new Error(`GET ${url} failed with HTTP 404 Not Found`); + } + return inner.getJson(url); + }, + }; + + const code = await run(["--mode=audit", `--repo-dir=${repoDir}`], { http, now: NOW }); + + expect(code).toBe(0); + expect(summary()).toContain("human-readable-checksum"); + expect(summary()).toContain("could not be age-checked"); + }); }); diff --git a/packages/dependency-cooldown/test/lockfiles.test.ts b/packages/dependency-cooldown/test/lockfiles.test.ts index be97684..95791fe 100644 --- a/packages/dependency-cooldown/test/lockfiles.test.ts +++ b/packages/dependency-cooldown/test/lockfiles.test.ts @@ -44,6 +44,32 @@ describe("npm package-lock.json", () => { ), ).toThrow(/lockfileVersion 1/); }); + + it("reports a local tarball as uncheckable rather than treating it as a registry version", () => { + const parsed = npmLockfile.parse( + JSON.stringify({ + lockfileVersion: 3, + packages: { + "": { name: "demo-app", version: "1.0.0" }, + "node_modules/human-readable-checksum": { + version: "0.3.0", + resolved: "file:package/human-readable-checksum-0.3.0.tgz", + }, + }, + }), + "package-lock.json", + ); + + expect(parsed.refs).toEqual([]); + expect(parsed.uncheckable).toEqual([ + { + name: "human-readable-checksum", + version: "0.3.0", + reason: + "not resolved from registry.npmjs.org (file:package/human-readable-checksum-0.3.0.tgz)", + }, + ]); + }); }); describe("bun.lock", () => { @@ -90,6 +116,67 @@ describe("bun.lock", () => { "workspace-lib", ]); }); + + it.each(["1.2.3-release.tgz", "1.0.0-beta.tar.gz", "2.0.0-rc.tar"])( + "collects registry semver %s even though it ends with a tarball extension", + (version) => { + const parsed = bunLockfile.parse( + `{ "packages": { "pkg": ["pkg@${version}", "", {}, "sha512-abcd"] } }`, + "bun.lock", + ); + + expect(parsed.uncheckable).toEqual([]); + expect(parsed.refs).toEqual([{ registry: "npm", name: "pkg", version }]); + }, + ); + + it("reports a relative local tarball as uncheckable rather than treating it as a registry version", () => { + const parsed = bunLockfile.parse( + '{ "packages": { "human-readable-checksum": ["human-readable-checksum@./package/human-readable-checksum-0.3.0.tgz", {}] } }', + "bun.lock", + ); + + expect(parsed.refs).toEqual([]); + expect(parsed.uncheckable).toEqual([ + { + name: "human-readable-checksum", + version: null, + reason: "resolved via local tarball", + }, + ]); + }); + + it.each([ + "../vendor/pkg-1.0.0.tgz", + "/opt/pkg-1.0.0.tgz", + "package/pkg-1.0.0.tgz", + "./1.2.3-release.tgz", + ])( + "reports local tarball specifier %s as uncheckable", + (specifier) => { + const parsed = bunLockfile.parse( + `{ "packages": { "pkg": ["pkg@${specifier}", {}] } }`, + "bun.lock", + ); + + expect(parsed.refs).toEqual([]); + expect(parsed.uncheckable).toEqual([ + { name: "pkg", version: null, reason: "resolved via local tarball" }, + ]); + }, + ); + + it("reports the lockfile root entry as uncheckable", () => { + const parsed = bunLockfile.parse( + '{ "packages": { "demo-app": ["demo-app@root:", { "bin": { "demo": "bin.js" } }] } }', + "bun.lock", + ); + + expect(parsed.refs).toEqual([]); + expect(parsed.uncheckable).toEqual([ + { name: "demo-app", version: null, reason: "resolved via root:" }, + ]); + }); }); describe("pubspec.lock", () => {