blob: dd8cb96628e9dd29fdf0648386686f6a1850f615 [file]
import { strict as assert } from 'node:assert';
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, win32, posix } from 'node:path';
import { afterEach, describe, test } from 'node:test';
import { isPathInside, realpathAllowMissing } from '../path-containment.js';
const cleanup: string[] = [];
afterEach(async () => {
await Promise.all(cleanup.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
describe('isPathInside', () => {
test('rejects cross-drive Windows targets (different drive is not inside root)', () => {
// path.win32.relative returns the target unchanged (absolute) when root
// and target are on different drives; this is the escape vector the helper
// must close before the `..` check.
assert.equal(isPathInside('C:\\repo', 'D:\\secret', win32), false);
});
test('rejects same-drive Windows targets outside root', () => {
assert.equal(isPathInside('C:\\repo', 'C:\\other\\secret', win32), false);
});
test('allows same-drive Windows targets under root', () => {
assert.equal(isPathInside('C:\\repo', 'C:\\repo\\sub', win32), true);
assert.equal(isPathInside('C:\\repo', 'C:\\repo', win32), true);
});
test('rejects parent-directory escape on POSIX', () => {
assert.equal(isPathInside('/repo', '/etc/passwd', posix), false);
});
test('allows POSIX targets under root', () => {
assert.equal(isPathInside('/repo', '/repo/sub', posix), true);
assert.equal(isPathInside('/repo', '/repo', posix), true);
});
test('allows paths whose first segment starts with ".." but is not a parent reference (e.g. ..rules)', () => {
assert.equal(isPathInside('/repo', '/repo/..rules/AGENTS.md', posix), true);
assert.equal(isPathInside('C:\\repo', 'C:\\repo\\..rules\\AGENTS.md', win32), true);
});
});
describe('realpathAllowMissing', () => {
test('canonicalises an existing path through a symlinked ancestor', async () => {
const { workspace, link } = await symlinkedWorkspace('maka-realpath-existing-');
await writeFile(join(workspace, 'file.txt'), 'x', 'utf8');
assert.equal(await realpathAllowMissing(join(link, 'file.txt')), join(workspace, 'file.txt'));
});
test('appends missing trailing segments to the deepest existing ancestor', async () => {
const { workspace, link } = await symlinkedWorkspace('maka-realpath-missing-');
assert.equal(
await realpathAllowMissing(join(link, 'absent', 'nested', 'file.txt')),
join(workspace, 'absent', 'nested', 'file.txt'),
);
});
test('follows a dangling symlink to its target, which is where a write would land', async () => {
// realpath fails ENOENT on a link whose target does not exist. Treating it
// as a plain missing leaf returns the link's own path, which reads as
// contained while a write through it escapes.
const { base, workspace, link } = await symlinkedWorkspace('maka-realpath-dangling-');
const outside = join(base, 'outside');
await mkdir(outside);
await symlink(join(outside, 'not-yet.txt'), join(workspace, 'dangling.txt'));
assert.equal(
await realpathAllowMissing(join(link, 'dangling.txt')),
join(outside, 'not-yet.txt'),
);
});
test('follows a chain of dangling links and keeps the segments below them', async () => {
const { base, workspace, link } = await symlinkedWorkspace('maka-realpath-chain-');
const outside = join(base, 'outside');
await mkdir(outside);
await symlink(join(outside, 'absent-dir'), join(workspace, 'hop-b'));
await symlink(join(workspace, 'hop-b'), join(workspace, 'hop-a'));
assert.equal(
await realpathAllowMissing(join(link, 'hop-a', 'file.txt')),
join(outside, 'absent-dir', 'file.txt'),
);
});
test('treats a non-directory ancestor as missing rather than throwing ENOTDIR', async () => {
const { workspace, link } = await symlinkedWorkspace('maka-realpath-notdir-');
await writeFile(join(workspace, 'file.txt'), 'x', 'utf8');
assert.equal(
await realpathAllowMissing(join(link, 'file.txt', 'child')),
join(workspace, 'file.txt', 'child'),
);
});
test('rejects a pathological dangling chain instead of walking it forever', async () => {
const { workspace } = await symlinkedWorkspace('maka-realpath-hops-');
// Every link resolves to the next and only the last one dangles. The kernel
// caps its own symlink traversal well below this length, so it answers ELOOP
// before the helper's hop cap can fire; either way the walk must terminate
// by rejecting rather than by hopping forever.
const chain = 64;
await symlink(join(workspace, 'absent'), join(workspace, `hop-${chain}`));
for (let index = chain; index > 0; index -= 1) {
await symlink(join(workspace, `hop-${index}`), join(workspace, `hop-${index - 1}`));
}
await assert.rejects(realpathAllowMissing(join(workspace, 'hop-0')), /ELOOP|too many/);
});
test('propagates a symlink cycle instead of walking forever', async () => {
const { workspace } = await symlinkedWorkspace('maka-realpath-cycle-');
await symlink(join(workspace, 'b'), join(workspace, 'a'));
await symlink(join(workspace, 'a'), join(workspace, 'b'));
await assert.rejects(realpathAllowMissing(join(workspace, 'a')), { code: 'ELOOP' });
});
});
async function symlinkedWorkspace(
prefix: string,
): Promise<{ base: string; workspace: string; link: string }> {
const base = await realpath(await mkdtemp(join(tmpdir(), prefix)));
cleanup.push(base);
const workspace = join(base, 'workspace');
const link = join(base, 'link-to-workspace');
await mkdir(workspace);
await symlink(workspace, link);
return { base, workspace, link };
}