fix(coding-agent): resolve junctions/symlinks when classifying omp update target
isPathInDirectory only normalized strings via path.resolve, so on Windows when Bun is installed via Scoop (~/.bun is a junction to scoop\persist\ Oven-sh.Bun\.bun) the omp path from $which and the bunBinDir from 'bun pm bin -g' compared as different directories, causing 'omp update' to take the binary-swap path instead of 'bun install -g' and fail with EPERM unlinking omp.exe.bak (Bun has the running exe open). Layer fs.realpathSync.native on top of the existing lexical guard, resolving the file's parent dir so non-existent target paths still fall through. Fixes #845
This commit is contained in:
@@ -53,13 +53,37 @@ function normalizePathForComparison(filePath: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isPathInDirectory(filePath: string, directoryPath: string): boolean {
|
||||
function tryRealpath(p: string): string | undefined {
|
||||
try {
|
||||
return fs.realpathSync.native(p);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInDirectoryLexical(filePath: string, directoryPath: string): boolean {
|
||||
const normalizedPath = normalizePathForComparison(path.resolve(filePath));
|
||||
const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath));
|
||||
const relativePath = path.relative(normalizedDirectory, normalizedPath);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function isPathInDirectory(filePath: string, directoryPath: string): boolean {
|
||||
if (isPathInDirectoryLexical(filePath, directoryPath)) return true;
|
||||
// Layer realpath resolution on top of the lexical guard. On Windows, ~/.bun
|
||||
// is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the
|
||||
// PATH-resolved omp path can refer to the same directory through different
|
||||
// strings. path.resolve does not traverse junctions/symlinks; realpath does.
|
||||
// Resolve the file's parent directory to tolerate the file itself not yet
|
||||
// existing (e.g. a fresh install path) while still catching link-traversed
|
||||
// equality once the directory exists.
|
||||
const fileDir = tryRealpath(path.dirname(path.resolve(filePath)));
|
||||
const dirReal = tryRealpath(path.resolve(directoryPath));
|
||||
if (!fileDir || !dirReal) return false;
|
||||
const resolvedFile = path.join(fileDir, path.basename(filePath));
|
||||
return isPathInDirectoryLexical(resolvedFile, dirReal);
|
||||
}
|
||||
|
||||
type UpdateTarget = { method: "bun" } | { method: "binary"; path: string };
|
||||
|
||||
function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { _resolveUpdateMethodForTest } from "../src/cli/update-cli";
|
||||
|
||||
// Issue #845: on Windows with Bun installed via Scoop, ~/.bun is a junction
|
||||
// to scoop\persist\Oven-sh.Bun\.bun. `bun pm bin -g` and the omp path that
|
||||
// $which finds may end up referring to the same directory through different
|
||||
// path strings (one through the junction, one through the real target).
|
||||
// `isPathInDirectory` did purely lexical comparison via path.resolve, which
|
||||
// does not follow filesystem links, so it misclassified Bun-installed omp
|
||||
// as "binary" and tried to swap omp.exe in place – which fails on Windows
|
||||
// because Bun has the file open (EPERM on unlink of .bak).
|
||||
//
|
||||
// We reproduce the realpath-resolution bug with a symlink (works on macOS /
|
||||
// Linux; the bug is realpath, not junction-specific).
|
||||
|
||||
describe("issue-845: resolveUpdateMethod follows symlinks/junctions", () => {
|
||||
let tmpRoot: string;
|
||||
let realBinDir: string;
|
||||
let linkedBinDir: string;
|
||||
let ompPathViaLink: string;
|
||||
|
||||
beforeAll(() => {
|
||||
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-issue-845-"));
|
||||
realBinDir = path.join(tmpRoot, "real", "bin");
|
||||
fs.mkdirSync(realBinDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(realBinDir, "omp"), "#!/bin/sh\n", { mode: 0o755 });
|
||||
|
||||
linkedBinDir = path.join(tmpRoot, "link-bin");
|
||||
fs.symlinkSync(realBinDir, linkedBinDir, "dir");
|
||||
ompPathViaLink = path.join(linkedBinDir, "omp");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("classifies omp reached through a symlinked bin dir as bun-managed", () => {
|
||||
// $which resolves through the symlink, `bun pm bin -g` returns the real path
|
||||
// (or vice versa). Either direction must be recognized.
|
||||
const method = _resolveUpdateMethodForTest(ompPathViaLink, realBinDir);
|
||||
expect(method).toBe("bun");
|
||||
});
|
||||
|
||||
it("classifies omp at the real bin dir as bun-managed when bunBinDir is symlinked", () => {
|
||||
const ompAtReal = path.join(realBinDir, "omp");
|
||||
const method = _resolveUpdateMethodForTest(ompAtReal, linkedBinDir);
|
||||
expect(method).toBe("bun");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user