-
Notifications
You must be signed in to change notification settings - Fork 965
fix(install): recover from non-directory entries blocking .bit_roots links #10355
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
Changes from all commits
143d4a9
36aacce
0929d2a
391e08a
98b2645
932da38
e23ed75
ed8e22d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,10 @@ import path from 'path'; | |
| import fs from 'fs-extra'; | ||
| import symlinkDir from 'symlink-dir'; | ||
| import resolveLinkTarget from 'resolve-link-target'; | ||
| import { formatWarningSummary } from '@teambit/cli'; | ||
| import { getConfig } from '@teambit/config-store'; | ||
| import { CFG_NO_WARNINGS } from '@teambit/legacy.constants'; | ||
| import { logger } from '@teambit/legacy.logger'; | ||
|
|
||
| /** | ||
| * Hard link all files from a directory to several target directories. | ||
|
|
@@ -20,11 +24,7 @@ export async function hardLinkDirectory(src: string, destDirs: string[]) { | |
| const destSubdirs = await Promise.all( | ||
| destDirs.map(async (destDir) => { | ||
| const destSubdir = path.join(destDir, file.name); | ||
| try { | ||
| await fs.mkdir(destSubdir, { recursive: true }); | ||
| } catch (err: any) { | ||
| if (err.code !== 'EEXIST') throw err; | ||
| } | ||
| await ensureDir(destSubdir); | ||
| return destSubdir; | ||
| }) | ||
| ); | ||
|
|
@@ -36,9 +36,9 @@ export async function hardLinkDirectory(src: string, destDirs: string[]) { | |
| let srcStats: fs.Stats; | ||
| try { | ||
| srcStats = await fs.stat(srcFile); | ||
| } catch (err: any) { | ||
| } catch (err) { | ||
| // if the link is broken, ignore it | ||
| if (err.code === 'ENOENT') return; | ||
| if (errnoCode(err) === 'ENOENT') return; | ||
| throw err; | ||
| } | ||
| if (srcStats.isDirectory()) { | ||
|
|
@@ -56,8 +56,8 @@ export async function hardLinkDirectory(src: string, destDirs: string[]) { | |
| const destFile = path.join(destDir, file.name); | ||
| try { | ||
| await linkFile(srcFile, destFile); | ||
| } catch (err: any) { | ||
| if (err.code === 'ENOENT') { | ||
| } catch (err) { | ||
| if (errnoCode(err) === 'ENOENT') { | ||
| // broken symlinks are skipped | ||
| return; | ||
| } | ||
|
|
@@ -72,18 +72,19 @@ export async function hardLinkDirectory(src: string, destDirs: string[]) { | |
| async function linkFile(srcFile: string, destFile: string) { | ||
| try { | ||
| await fs.link(srcFile, destFile); | ||
| } catch (err: any) { | ||
| if (err.code === 'ENOENT') { | ||
| await fs.mkdir(path.dirname(destFile), { recursive: true }); | ||
| } catch (err) { | ||
| const code = errnoCode(err); | ||
| if (code === 'ENOENT' || code === 'ENOTDIR') { | ||
| await ensureDir(path.dirname(destFile)); | ||
| await linkFileIfNotExists(srcFile, destFile); | ||
| return; | ||
| } | ||
| if (err.code === 'EXDEV') { | ||
| if (code === 'EXDEV') { | ||
| // hard links can't cross devices (e.g. bind mounts or overlayfs on CI), fall back to copying | ||
| await fs.copyFile(srcFile, destFile); | ||
| return; | ||
| } | ||
| if (err.code !== 'EEXIST') { | ||
| if (code !== 'EEXIST') { | ||
| throw err; | ||
| } | ||
| } | ||
|
|
@@ -92,13 +93,121 @@ async function linkFile(srcFile: string, destFile: string) { | |
| async function linkFileIfNotExists(srcFile: string, destFile: string) { | ||
| try { | ||
| await fs.link(srcFile, destFile); | ||
| } catch (err: any) { | ||
| if (err.code === 'EXDEV') { | ||
| } catch (err) { | ||
| const code = errnoCode(err); | ||
| if (code === 'EXDEV') { | ||
| await fs.copyFile(srcFile, destFile); | ||
| return; | ||
| } | ||
| if (err.code !== 'EEXIST') { | ||
| if (code !== 'EEXIST') { | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Like `fs.mkdir(dir, { recursive: true })`, but recovers from a corrupted node_modules | ||
| * tree where some ancestor of `dir` exists as a regular file or a non-directory symlink | ||
| * (which causes `mkdir` to throw `ENOTDIR` or `ENOENT` through a broken symlink). The | ||
| * blocking entry is moved aside (not deleted — the offender could be high up the tree | ||
| * and we don't want to discard the user's data) and `mkdir` is retried. | ||
| */ | ||
| async function ensureDir(dir: string) { | ||
| let mkdirError: unknown; | ||
| for (let attempt = 0; attempt < 10; attempt++) { | ||
| try { | ||
| await fs.mkdir(dir, { recursive: true }); | ||
| return; | ||
| } catch (err) { | ||
| mkdirError = err; | ||
| } | ||
|
|
||
| // ENOTDIR: a regular file blocks the path. EEXIST: leaf already exists as a non-directory | ||
| // (rare with recursive: true). ENOENT: a dangling symlink in the path can't be traversed. | ||
| const code = errnoCode(mkdirError); | ||
| if (code !== 'ENOTDIR' && code !== 'EEXIST' && code !== 'ENOENT') throw mkdirError; | ||
| const offender = await findNonDirectoryAncestor(dir); | ||
| if (offender == null) { | ||
| // EEXIST with a directory already at `dir` is benign — recursive mkdir normally | ||
| // swallows it, but be defensive against races. | ||
| if (code === 'EEXIST') return; | ||
| // Another worker may have already moved the offender. Retry mkdir against the new state. | ||
| continue; | ||
| } | ||
| const quarantined = await quarantineStrayEntry(offender); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Recovery moves arbitrary path entries ensureDir quarantines blockers without checking that they belong to a generated Bit directory, while injected destinations can be arbitrary absolute paths read from package-manager metadata. A stale or malformed injected location can therefore cause an unrelated file or symlink outside the workspace to be moved during compilation. Agent Prompt
|
||
| // Another worker already quarantined this entry. Retry mkdir against the new state. | ||
| if (quarantined == null) continue; | ||
| const msg = | ||
| `non-directory entry at ${offender} blocked link target ${dir}; ` + | ||
| `moved aside to ${quarantined} so the install could continue. inspect or delete it manually if it isn't expected.`; | ||
| logger.warn(msg); | ||
| printRecoveryWarning(msg); | ||
| } | ||
| throw mkdirError; | ||
| } | ||
|
|
||
| /** | ||
| * Reserve a unique sibling directory, then atomically move `offender` into it. The empty, | ||
| * exclusively created directory makes the rename no-clobber without recreating files or | ||
| * symlinks, so Windows junction types and all other entry metadata remain intact. | ||
| * On the rare chance the directory name already exists (e.g. a previous recovery in the | ||
| * same millisecond, or a leftover from a prior failed run), keep bumping a counter. | ||
| * Returns undefined when another worker already moved the offender. | ||
| */ | ||
| async function quarantineStrayEntry(offender: string): Promise<string | undefined> { | ||
| const base = `${offender}.bit-stray-${Date.now()}`; | ||
| let quarantineDir = base; | ||
| for (let i = 1; ; i++) { | ||
| try { | ||
| await fs.mkdir(quarantineDir, { mode: 0o700 }); | ||
| } catch (err) { | ||
| if (errnoCode(err) !== 'EEXIST') throw err; | ||
| quarantineDir = `${base}-${i}`; | ||
| continue; | ||
| } | ||
|
|
||
| const quarantined = path.join(quarantineDir, path.basename(offender)); | ||
| try { | ||
| await fs.rename(offender, quarantined); | ||
| return quarantined; | ||
|
Comment on lines
+169
to
+172
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Quarantine retargets relative symlinks Quarantine moves an offending symlink from its original parent into a nested directory, so any relative link target is subsequently resolved from a different directory. The entry is retained, but it no longer references the same path and cannot be restored later with its original behavior. Agent Prompt
|
||
| } catch (err) { | ||
| // Only remove the directory if it is still empty. Never recursively remove it: after a | ||
| // successful rename it contains user data, even if an unusual filesystem reports an error. | ||
| await fs.rmdir(quarantineDir).catch(() => undefined); | ||
| if (errnoCode(err) === 'ENOENT') return undefined; | ||
| throw err; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function printRecoveryWarning(msg: string) { | ||
| if (getConfig(CFG_NO_WARNINGS) === 'true' || !logger.shouldWriteToConsole) return; | ||
| logger.console(formatWarningSummary(msg), 'warn'); | ||
| } | ||
|
|
||
| /** | ||
| * Walk up from `dir` until we find an existing path component. If that component is not | ||
| * a directory, return it (it's the entry blocking `mkdir`). Otherwise return null. | ||
| */ | ||
| async function findNonDirectoryAncestor(dir: string): Promise<string | null> { | ||
| let current = dir; | ||
| while (current && path.dirname(current) !== current) { | ||
| let stat: fs.Stats; | ||
| try { | ||
| stat = await fs.lstat(current); | ||
| } catch (err) { | ||
| const code = errnoCode(err); | ||
| if (code === 'ENOENT' || code === 'ENOTDIR') { | ||
| current = path.dirname(current); | ||
| continue; | ||
| } | ||
| throw err; | ||
| } | ||
| return stat.isDirectory() ? null : current; | ||
| } | ||
| return null; | ||
| } | ||
|
|
||
| function errnoCode(err: unknown): string | undefined { | ||
| return (err as NodeJS.ErrnoException | undefined)?.code; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.