Skip to content
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
12 changes: 11 additions & 1 deletion workspaces/arborist/lib/arborist/reify.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const { callLimit: promiseCallLimit } = require('promise-call-limit')
const { depth: dfwalk } = require('treeverse')
const { dirname, resolve, relative, join, sep } = require('node:path')
const { log, time } = require('proc-log')
const { existsSync } = require('node:fs')
const { existsSync, realpathSync } = require('node:fs')
const { lstat, mkdir, readdir, readlink, rm, symlink } = require('node:fs/promises')
const { moveFile } = require('@npmcli/fs')
const { subset, intersects } = require('semver')
Expand Down Expand Up @@ -869,6 +869,10 @@ module.exports = cls => class Reifier extends cls {
if (child.isLink && child.resolved?.startsWith('file:.store/') && !existsSync(child.realpath)) {
continue
}
// Skip a link whose on-disk target is a valid-but-wrong store key (e.g. an interrupted update), so the diff repoints it via ADD.
if (child.isLink && this.#linkTargetMismatch(child)) {
continue
}
let entry
if (child.isLink) {
entry = new IsolatedLink(child)
Expand Down Expand Up @@ -908,6 +912,12 @@ module.exports = cls => class Reifier extends cls {
return wrapper
}

// True when the link's on-disk target resolves to a different path than its ideal target.
// The caller only invokes this once both paths exist, so realpathSync won't throw.
#linkTargetMismatch (child) {
return realpathSync(child.path) !== realpathSync(child.realpath)
}

// When extracting a registry-resolved package, the spec we hand to pacote is name@URL.
// pacote re-parses that with npa and gets spec.type === 'remote', so without an override the allow-remote gate would fire on every registry tarball (both =none and =root mis-fire).
// Returns true only when we are confident this is a registry-mediated install.
Expand Down
36 changes: 36 additions & 0 deletions workspaces/arborist/test/isolated-mode.js
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,42 @@ tap.test('idempotent install with legacyPeerDeps and workspace peer deps', async
}
})

tap.test('reinstall repairs a wrong-but-existing top-level symlink target', async t => {
// Regression for https://github.com/npm/cli/issues/9611: an interrupted update can leave a top-level symlink pointing at a valid-but-wrong store key. Reinstall must repoint it, not treat it as already-correct.
// root depends on a@1.0.0 directly and on b@1.0.0, which pulls a@2.0.0 — so both versions of `a` live in the store.
const graph = {
registry: [
{ name: 'a', version: '1.0.0' },
{ name: 'a', version: '2.0.0' },
{ name: 'b', version: '1.0.0', dependencies: { a: '2.0.0' } },
],
root: {
name: 'myroot', version: '1.0.0', dependencies: { a: '1.0.0', b: '1.0.0' },
},
}

const { dir, registry } = await getRepo(graph)
const cache = fs.mkdtempSync(`${getTempDir()}/test-`)
const opts = { path: dir, registry, packumentCache: new Map(), cache }

await new Arborist(opts).reify({ installStrategy: 'linked' })

const topLink = path.join(dir, 'node_modules', 'a')
const readVersion = p => JSON.parse(fs.readFileSync(path.join(p, 'package.json'), 'utf8')).version
t.equal(readVersion(fs.realpathSync(topLink)), '1.0.0', 'top-level a resolves to 1.0.0 after install')

// Simulate an interrupted update: repoint node_modules/a at the wrong (but real) a@2.0.0 store key.
const storeDir = path.join(dir, 'node_modules', '.store')
const wrongKey = fs.readdirSync(storeDir).find(k => k.startsWith('a@2.0.0'))
fs.unlinkSync(topLink)
fs.symlinkSync(path.join('.store', wrongKey, 'node_modules', 'a'), topLink, 'junction')
t.equal(readVersion(fs.realpathSync(topLink)), '2.0.0', 'symlink corrupted to 2.0.0 before reinstall')

await new Arborist({ ...opts, packumentCache: new Map() }).reify({ installStrategy: 'linked' })

t.equal(readVersion(fs.realpathSync(topLink)), '1.0.0', 'reinstall repaired the symlink back to 1.0.0')
})

tap.test('Lock file is same in hoisted and in isolated mode', async t => {
const graph = {
registry: [
Expand Down
Loading