import fs from "node:fs/promises" ;
import path from "node:path" ;
import { describe, expect, it } from "vitest" ;
import { withTempDir } from "../test-helpers/temp-dir.js" ;
import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js" ;
import { isPathInside } from "./path-guards.js" ;
function createSeededRandom(seed: number): () => number {
let state = seed >>> 0 ;
return () => {
state = (state * 1664525 + 1013904223 ) >>> 0 ;
return state / 0 x100000000;
};
}
describe("resolveBoundaryPath" , () => {
it("resolves symlink parents with non-existent leafs inside root" , async () => {
if (process.platform === "win32" ) {
return ;
}
await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => {
const root = path.join(base, "workspace" );
const targetDir = path.join(root, "target-dir" );
const linkPath = path.join(root, "alias" );
await fs.mkdir(targetDir, { recursive: true });
await fs.symlink(targetDir, linkPath);
const unresolved = path.join(linkPath, "missing.txt" );
const result = await resolveBoundaryPath({
absolutePath: unresolved,
rootPath: root,
boundaryLabel: "sandbox root" ,
});
const targetReal = await fs.realpath(targetDir);
expect(result.exists).toBe(false );
expect(result.kind).toBe("missing" );
expect(result.canonicalPath).toBe(path.join(targetReal, "missing.txt" ));
expect(isPathInside(result.rootCanonicalPath, result.canonicalPath)).toBe(true );
});
});
it("blocks dangling symlink leaf escapes outside root" , async () => {
if (process.platform === "win32" ) {
return ;
}
await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => {
const root = path.join(base, "workspace" );
const outside = path.join(base, "outside" );
const linkPath = path.join(root, "alias-out" );
await fs.mkdir(root, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.symlink(outside, linkPath);
const dangling = path.join(linkPath, "missing.txt" );
await expect(
resolveBoundaryPath({
absolutePath: dangling,
rootPath: root,
boundaryLabel: "sandbox root" ,
}),
).rejects.toThrow(/Symlink escapes sandbox root/i);
expect(() =>
resolveBoundaryPathSync({
absolutePath: dangling,
rootPath: root,
boundaryLabel: "sandbox root" ,
}),
).toThrow(/Symlink escapes sandbox root/i);
});
});
it("allows final symlink only when unlink policy opts in" , async () => {
if (process.platform === "win32" ) {
return ;
}
await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => {
const root = path.join(base, "workspace" );
const outside = path.join(base, "outside" );
const outsideFile = path.join(outside, "target.txt" );
const linkPath = path.join(root, "link.txt" );
await fs.mkdir(root, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.writeFile(outsideFile, "x" , "utf8" );
await fs.symlink(outsideFile, linkPath);
await expect(
resolveBoundaryPath({
absolutePath: linkPath,
rootPath: root,
boundaryLabel: "sandbox root" ,
}),
).rejects.toThrow(/Symlink escapes sandbox root/i);
const allowed = await resolveBoundaryPath({
absolutePath: linkPath,
rootPath: root,
boundaryLabel: "sandbox root" ,
policy: { allowFinalSymlinkForUnlink: true },
});
const rootReal = await fs.realpath(root);
expect(allowed.exists).toBe(true );
expect(allowed.kind).toBe("symlink" );
expect(allowed.canonicalPath).toBe(path.join(rootReal, "link.txt" ));
});
});
it("allows canonical aliases that still resolve inside root" , async () => {
if (process.platform === "win32" ) {
return ;
}
await withTempDir({ prefix: "openclaw-boundary-path-" }, async (base) => {
const root = path.join(base, "workspace" );
const aliasRoot = path.join(base, "workspace-alias" );
const fileName = "plugin.js" ;
await fs.mkdir(root, { recursive: true });
await fs.writeFile(path.join(root, fileName), "export default {}" , "utf8" );
await fs.symlink(root, aliasRoot);
const resolved = await resolveBoundaryPath({
absolutePath: path.join(aliasRoot, fileName),
rootPath: await fs.realpath(root),
boundaryLabel: "plugin root" ,
});
expect(resolved.exists).toBe(true );
expect(isPathInside(resolved.rootCanonicalPath, resolved.canonicalPath)).toBe(true );
const resolvedSync = resolveBoundaryPathSync({
absolutePath: path.join(aliasRoot, fileName),
rootPath: await fs.realpath(root),
boundaryLabel: "plugin root" ,
});
expect(resolvedSync.exists).toBe(true );
expect(isPathInside(resolvedSync.rootCanonicalPath, resolvedSync.canonicalPath)).toBe(true );
});
});
it("maintains containment invariant across randomized alias cases" , async () => {
if (process.platform === "win32" ) {
return ;
}
await withTempDir({ prefix: "openclaw-boundary-path-fuzz-" }, async (base) => {
const root = path.join(base, "workspace" );
const outside = path.join(base, "outside" );
const safeTarget = path.join(root, "safe-target" );
const safeRealBase = path.join(root, "safe-real" );
const safeLinkBase = path.join(root, "safe-link" );
const escapeLink = path.join(root, "escape-link" );
await fs.mkdir(root, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await fs.mkdir(safeTarget, { recursive: true });
await fs.mkdir(safeRealBase, { recursive: true });
await fs.symlink(safeTarget, safeLinkBase);
await fs.symlink(outside, escapeLink);
const rand = createSeededRandom(0 x5eed1234);
const fuzzCases = 32 ;
for (let idx = 0 ; idx < fuzzCases; idx += 1 ) {
const token = Math.floor(rand() * 1 _000 _000 )
.toString(16 )
.padStart(5 , "0" );
const useLink = rand() > 0 .5 ;
const safeBase = useLink ? safeLinkBase : safeRealBase;
const safeCandidate = path.join(safeBase, `new -${token}.txt`);
const safeResolved = await resolveBoundaryPath({
absolutePath: safeCandidate,
rootPath: root,
boundaryLabel: "sandbox root" ,
});
expect(isPathInside(safeResolved.rootCanonicalPath, safeResolved.canonicalPath)).toBe(true );
const unsafeCandidate = path.join(escapeLink, `new -${token}.txt`);
await expect(
resolveBoundaryPath({
absolutePath: unsafeCandidate,
rootPath: root,
boundaryLabel: "sandbox root" ,
}),
).rejects.toThrow(/Symlink escapes sandbox root/i);
}
});
});
});
Messung V0.5 in Prozent C=100 H=98 G=98
¤ Dauer der Verarbeitung: 0.11 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland