pnpm 全局安装 fail-closed 修复:`add -g` / `update -g` / `remove -g` 如何避免半读取损坏全局环境
pnpm 全局安装 fail-closed 修复add -g/update -g/remove -g如何避免半读取损坏全局环境【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm本篇技术指南围绕 pnpm 仓库中 fail-closed-global-bin-enumeration.md 这一 changeset 记录的缺陷修复展开深入讲解pnpm add -g、pnpm update -g、pnpm remove -g三条命令在枚举全局安装包时由容错继续改为遇到问题即中止fail-closed的行为变更。读完本文你将理解全局包组package group的存储与激活机制、包清单缺失/损坏/不可读时旧版为何会破坏全局 bin 目录以及新版如何保证在失败时完整保留现有全局安装。修复背景全局安装的包组结构与两条读取路径pnpm 的全局安装与普通项目安装不同pnpm add -g会把每个安装参数或逗号分隔的一组选择器解析成一个独立的安装组install group每组都拥有自己的独立安装目录而全局目录globalPkgDir下的哈希链接hash link即指向安装目录的符号链接负责把各组登记进全局命名空间。在 pnpm11/global/packages/src/scanGlobalPackages.ts 中可以清楚看到这套模型scanGlobalPackages读取全局目录下所有符号链接条目对每个链接执行fs.realpathSync找到真实安装目录再读取该目录下的package.json把其中经过isValidGlobalDependencyAlias校验的依赖别名整理成GlobalPackageInfo。也就是说一个全局组的信息完全来自安装目录里的 package.json 安装目录/node_modules 下的各依赖 manifest。这个修复涉及两条关键读取路径扫描路径scanscanGlobalPackages与getGlobalPackageDetails用于列出、冲突检查、归属计算等只读盘点场景组内清单读取readreadInstalledPackages.ts 中readInstalledPackages用于在激活activation之前读取新安装组内每个包的完整DependencyManifest。缺陷本质只部分读取就动刀旧实现的问题在于枚举全局 bin 或读取组内 manifest 时遇到缺失、损坏、不可读的包清单往往选择跳过或继续而后续步骤激活新组、移除旧组、清理被替换的 bin又依赖枚举结果。一旦某个包清单无法读取命令就会在信息不完整的状态下继续执行导致pnpm add -g新组激活后把旧组替换/清理掉时依据的是不完整的 bin 清单可能误删其他全局包仍然拥有的 binshimpnpm update -g更新组并切换哈希链接后依据不完整清单移除消失的 bin可能把仍被引用的命令入口删掉pnpm remove -g计算 bin 归属时缺了某个组的清单把本该保留的 bin 一并 unlink甚至移除整个安装目录。简而言之读得越少删得越多。这正是 changeset 中mutating global bins or install directories after only partially reading an installed package group所描述的缺陷对应上游 issue pnpm/pnpm#13796 的记录文章不展开外部链接细节。修复方案fail-closed —— 读不全就拒绝动手changeset 给出的修复策略非常直接如果任何一个声明的包清单缺失、格式错误或不可读pnpm 就在激活或移除之前失败并让现有全局安装保持原样。用安全术语讲就是从fail-open读不到就跳过继续切换为fail-closed读不到就整体中止。行为变化可归纳为下表命令修复前fail-open修复后fail-closedpnpm add -g部分读取新组清单后继续激活、替换旧组新组内任一清单不可读即中止新目录被清理旧安装不变pnpm update -g部分读取后切换哈希链接并清理被替换组更新组清单不可读即中止旧哈希链接与旧组保持原样pnpm remove -g部分读取后 unlink bin、删除安装目录目标组清单不可读即中止任何 bin 与目录都不被触碰源码级剖析fail-closed 在三条命令中的落点1. 激活前的严格读取readInstalledPackagesreadInstalledPackages.ts 是整个修复的核心新增点。它先读取安装目录的package.json用isValidGlobalDependencyAlias过滤出合法的依赖别名然后并发调用readPackageJsonFromDir来自pnpm/pkg-manifest.reader的严格读取器读取node_modules下每个依赖的 manifestexport async function readInstalledPackages (installDir: string): PromiseInstalledGroupPackage[] { const pkgJson readPackageJsonFromDirRawSync(installDir) const depNames Object.keys(pkgJson.dependencies ?? {}).filter(isValidGlobalDependencyAlias) const manifests await Promise.all( depNames.map((depName) readPackageJsonFromDir(path.join(installDir, node_modules, depName))) ) return depNames.map((depName, i) ({ alias: depName, manifest: manifests[i] as DependencyManifest, location: path.join(installDir, node_modules, depName), })) }关键点在于使用了readPackageJsonFromDir严格版而非safeReadPackageJsonFromDir容错版只要某个依赖目录里没有package.json、JSON 解析失败或不可读Promise 就会 reject调用方在激活前拿到错误并中止。readInstalledPackages的调用方包括globalAdd.ts 的installGroup在checkGlobalBinConflicts、getActualBinNames、activateGlobalInstall之前调用第 142 行附近globalUpdate.ts 的updateGlobalPackageGroup同样在冲突检查与激活之前调用第 107 行附近。这两处都属于新组已经装好、但还没对外激活的阶段因此失败时还有退路调用cleanupFailedGlobalInstall把新目录删掉即可。2. 移除/更新前的归属计算scanGlobalPackages与getInstalledBinNames移除和更新场景面向的是已存在的全局组读取入口在 scanGlobalPackages.tsscanGlobalPackages对每个哈希链接解析真实路径并读取package.json单组读取失败时旧版会continue静默跳过第 83-88 行的 try/catch这是 fail-open 的一个典型来源真正决定哪些 bin 属于哪个组的是getInstalledBinNames第 155-169 行它遍历组的dependencies对每个别名调用readPackageJsonFromDir严格版并交给getBinsFromPackageManifest展开 bin 清单。这里只要有一个包的 manifest 读不到就会抛错Promise.all直接 reject。getInstalledBinNames被 binOwnership.ts 的getGlobalBinOwnership使用它同时计算目标组拥有的 bin与幸存组不在本次操作范围内的其他全局组拥有的 bin进而得出protectedBins被幸存组共享、不得删除的 bin。如果枚举在读取环节就失败归属计算根本不会返回globalRemove.ts 会在任何removeBin或fs.promises.rm执行之前直接抛出GLOBAL_PKG_NOT_FOUND之外的读取错误——这就是 remove 场景的 fail-closed 落点。3. 失败后的善后cleanupFailedGlobalInstall与激活回滚对于add -g/update -gfail-closed 的保证依赖两段清理逻辑cleanupFailedGlobalInstall.ts删除新安装目录后重新抛出原始错误。注释写得很清楚——Nothing outside the directory has been touched yet, so removing it leaves the global installation exactly as the command found it目录之外尚未被触碰删除它即可让全局安装与命令执行前完全一致。若删除失败会构造AggregateError并把原始错误的code透传出去保证报错输出可被解析globalActivation.ts 的activateGlobalInstall真正激活前先prepareGlobalInstall备份 bin 槽位.pnpm-bin-backup-*、记录旧哈希链接目标切换链接失败时执行restoreGlobalInstall回滚回滚失败才抛出GLOBAL_BIN_ROLLBACK_FAILED。这意味着即使失败发生在激活阶段也存在恢复机制。值得一提的是globalAdd.ts 与 globalUpdate.ts 中多处对枚举类调用checkGlobalBinConflicts、getActualBinNames、collectExistingGlobalInstalls都包在try/catch中并统一走cleanupFailedGlobalInstall正是读不全即中止且不留副作用策略的执行骨架。防御纵深不止读严格还有名字严格与 fail-closed 修复配套的还有输入校验层面的防御。scanGlobalPackages.ts 中的isValidGlobalDependencyAlias第 21-33 行解释了为什么依赖别名会被过滤全局组的package.json依赖别名会在 list、冲突检查、remove、update 等环节被拼进node_modules下的目录名被篡改的 manifest 完全可以用../x或绝对路径让路径逃离安装目录。因此该函数内联实现了validate-npm-package-name的validForOldPackages规则拒绝空名、前导._-、首尾空白、保留名node_modules/favicon.ico、URL 非友好字符等只信任合法的 npm 包名。这属于同一信任边界收紧修复思路的另一半与 fail-closed 共同构成对全局安装完整性的保护。受影响包与验证方式changeset 的 frontmatter 声明该 patch 影响以下包pnpm/global.commands: patch pnpm/global.packages: patch pnpm: patch pacquet: patch即修复同时作用于pnpm/global.commands命令编排层与pnpm/global.packages扫描/枚举层两个工作区包并随之发布到 pnpm 主包与 pacquet。仓库中已有对应的测试覆盖可作行为佐证getInstalledBinNames.test.ts验证 bin 枚举在 manifest 缺失/异常时的行为globalAdd.test.ts、globalUpdate.test.ts、globalRemove.test.ts分别覆盖三条命令在异常输入下不破坏现有全局安装的路径globalActivation.test.ts验证激活失败时的回滚路径。升级与自查建议升级到包含该 changeset 的 pnpm 版本后如果全局目录中存在历史遗留的半损坏安装组例如某个组node_modules/pkg下丢了package.jsonadd -g/update -g/remove -g可能比旧版更容易报错——这是 fail-closed 的预期行为提示你需要先修复或清理对应组而不是让命令在残缺状态下继续动 bin排查时可先执行pnpm list -g观察输出再从globalPkgDir下手动检查异常组需要说明的是本文所有行为描述均以当前仓库源码为准pnpm 采用先装入新目录、验证清单、再原子切换哈希链接、最后清理被替换组的流水线见 globalActivation.ts 的swapHashLink注释fail-closed 修复正是为这条流水线补上了前置校验这一环确保任何一步读到坏数据时整条流水线都不会触碰已有全局 bin 与安装目录。【免费下载链接】pnpmFast, disk space efficient package manager项目地址: https://gitcode.com/gh_mirrors/pn/pnpm创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考