Repository Public
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import { mkdirSync, symlinkSync, writeFileSync, existsSync } from 'node:fs';3import { execFileSync } from 'node:child_process';4import net from 'node:net';5import path from 'node:path';6import { makeConfig, makeTmp, rmTmp } from './fsx-testkit.js';7import { assertRootAllowed, isInside, resolveInside, resolveInsideEx, splitRel, toRel } from './paths.js';8import { RtError } from './plugins.js';9import type { RuntimeConfig } from './config.js';1011let tmp: string;12let ws: string; // configured workspace root13let proj: string; // project root inside ws14let outside: string; // sibling area NOT in any workspace root15let config: RuntimeConfig;1617async function code(p: Promise<unknown>): Promise<string> {18 try {19 await p;20 } catch (e) {21 if (e instanceof RtError) return `${e.status}:${e.code}`;22 throw e;23 }24 return 'ok';25}2627beforeAll(() => {28 tmp = makeTmp('cr-paths-');29 ws = path.join(tmp, 'ws');30 proj = path.join(ws, 'proj');31 outside = path.join(tmp, 'outside');32 mkdirSync(path.join(proj, 'sub', 'deep'), { recursive: true });33 mkdirSync(path.join(ws, 'proj-evil'), { recursive: true });34 mkdirSync(path.join(outside, 'dir'), { recursive: true });35 writeFileSync(path.join(proj, 'a.txt'), 'a');36 writeFileSync(path.join(proj, 'sub', 'b.txt'), 'b');37 writeFileSync(path.join(ws, 'proj-evil', 'secret.txt'), 'evil');38 writeFileSync(path.join(outside, 'secret.txt'), 'secret');39 writeFileSync(path.join(outside, 'dir', 'x.txt'), 'x');40 // symlinks41 symlinkSync(path.join(outside, 'secret.txt'), path.join(proj, 'link-file-out'));42 symlinkSync(path.join(outside, 'dir'), path.join(proj, 'link-dir-out'));43 symlinkSync(path.join(proj, 'a.txt'), path.join(proj, 'link-file-in'));44 symlinkSync(path.join(proj, 'sub'), path.join(proj, 'link-dir-in'));45 symlinkSync('../../outside', path.join(proj, 'link-rel-out'));46 symlinkSync(path.join(outside, 'nope'), path.join(proj, 'link-dangling-out'));47 symlinkSync(path.join(proj, 'not-yet'), path.join(proj, 'link-dangling-in'));48 // chain: c1 -> c2 -> c3 -> outside ; and an inside chain49 symlinkSync(path.join(outside, 'dir'), path.join(proj, 'c3'));50 symlinkSync('c3', path.join(proj, 'c2'));51 symlinkSync('c2', path.join(proj, 'c1'));52 symlinkSync('sub', path.join(proj, 'i2'));53 symlinkSync('i2', path.join(proj, 'i1'));54 symlinkSync('loop-b', path.join(proj, 'loop-a'));55 symlinkSync('loop-a', path.join(proj, 'loop-b'));56 symlinkSync(path.join(ws, 'proj-evil'), path.join(proj, 'link-sibling'));57 // a symlink whose name needs unicode normalisation (NFC "é")58 symlinkSync(path.join(outside, 'dir'), path.join(proj, 'caf\u00e9'));59 config = makeConfig([ws]);60});61afterAll(() => rmTmp(tmp));6263describe('isInside / toRel', () => {64 it('uses a trailing separator, so prefix siblings are not inside', () => {65 expect(isInside('/ws/proj', '/ws/proj')).toBe(true);66 expect(isInside('/ws/proj', '/ws/proj/a')).toBe(true);67 expect(isInside('/ws/proj', '/ws/proj-evil')).toBe(false);68 expect(isInside('/ws/proj', '/ws/proj-evil/a')).toBe(false);69 expect(isInside('/ws/proj', '/ws')).toBe(false);70 });71 it('produces POSIX relative paths and refuses outside paths', () => {72 expect(toRel('/ws/proj', '/ws/proj')).toBe('');73 expect(toRel('/ws/proj', '/ws/proj/a/b.txt')).toBe('a/b.txt');74 expect(() => toRel('/ws/proj', '/ws/proj-evil/a')).toThrow(RtError);75 });76});7778describe('splitRel (syntax)', () => {79 it('normalises harmless forms', () => {80 expect(splitRel('')).toEqual([]);81 expect(splitRel('.')).toEqual([]);82 expect(splitRel('a//b/./c/')).toEqual(['a', 'b', 'c']);83 });84 it.each([85 ['..'],86 ['../x'],87 ['a/../b'],88 ['a/..'],89 ['a\\..\\b'],90 ['..\\x'],91 ['a/\uFF0E\uFF0E/b'], // fullwidth dots: NFKC-normalise to ".."92 ['\u2025/x'], // TWO DOT LEADER: NFKC-normalises to ".."93 ])('rejects parent traversal %j', (rel) => {94 expect(() => splitRel(rel)).toThrow(/\.\./);95 });96 it.each([['/etc/passwd'], ['//x'], ['\\x'], ['C:\\x'], ['\\\\server\\share']])('rejects absolute %j', (rel) => {97 expect(() => splitRel(rel)).toThrow(/relative/);98 });99 it('rejects NUL bytes, non-strings and over-long paths', () => {100 expect(() => splitRel('a\0b')).toThrow(/NUL/);101 expect(() => splitRel(42)).toThrow(RtError);102 expect(() => splitRel(undefined)).toThrow(RtError);103 expect(() => splitRel('a/'.repeat(3000))).toThrow(/too long/);104 expect(() => splitRel('x'.repeat(256))).toThrow(/too long/);105 });106 it('keeps names that merely contain dots', () => {107 expect(splitRel('...')).toEqual(['...']);108 expect(splitRel('a..b/..c/d..')).toEqual(['a..b', '..c', 'd..']);109 });110});111112describe('assertRootAllowed', () => {113 it('accepts the workspace root and directories below it', async () => {114 expect(await assertRootAllowed(config, ws)).toBe(ws);115 expect(await assertRootAllowed(config, proj)).toBe(proj);116 expect(await assertRootAllowed(config, proj + '/')).toBe(proj);117 });118 it('refuses roots outside every workspace root', async () => {119 expect(await code(assertRootAllowed(config, outside))).toBe('403:root_not_allowed');120 expect(await code(assertRootAllowed(config, tmp))).toBe('403:root_not_allowed');121 expect(await code(assertRootAllowed(config, '/'))).toBe('403:root_not_allowed');122 expect(await code(assertRootAllowed(config, '/etc'))).toBe('403:root_not_allowed');123 });124 it('refuses a prefix sibling of the workspace root', async () => {125 const cfg = makeConfig([proj]);126 expect(await code(assertRootAllowed(cfg, path.join(ws, 'proj-evil')))).toBe('403:root_not_allowed');127 });128 it('refuses a root that is a symlink leaving the workspace', async () => {129 expect(await code(assertRootAllowed(config, path.join(proj, 'link-dir-out')))).toBe('403:root_not_allowed');130 });131 it('refuses lexical escapes, relative, missing, file and malformed roots', async () => {132 expect(await code(assertRootAllowed(config, path.join(proj, '..', '..', 'outside')))).toBe('403:root_not_allowed');133 expect(await code(assertRootAllowed(config, 'ws/proj'))).toBe('400:bad_root');134 expect(await code(assertRootAllowed(config, ''))).toBe('400:bad_root');135 expect(await code(assertRootAllowed(config, proj + '\0'))).toBe('400:bad_root');136 expect(await code(assertRootAllowed(config, 12))).toBe('400:bad_root');137 expect(await code(assertRootAllowed(config, path.join(ws, 'missing')))).toBe('404:root_not_found');138 expect(await code(assertRootAllowed(config, path.join(proj, 'a.txt')))).toBe('400:bad_root');139 });140 it('realpaths the configured roots (symlinked workspace root)', async () => {141 const alias = path.join(tmp, 'ws-alias');142 if (!existsSync(alias)) symlinkSync(ws, alias);143 const cfg = makeConfig([alias]);144 expect(await assertRootAllowed(cfg, proj)).toBe(proj);145 expect(await assertRootAllowed(cfg, path.join(alias, 'proj'))).toBe(proj);146 });147 it('grants nothing when no workspace root exists', async () => {148 const cfg = makeConfig([path.join(tmp, 'does-not-exist')]);149 expect(await code(assertRootAllowed(cfg, proj))).toBe('403:root_not_allowed');150 });151});152153describe('resolveInside', () => {154 it('resolves existing and root paths', async () => {155 expect(await resolveInside(config, proj, '')).toBe(proj);156 expect(await resolveInside(config, proj, 'a.txt')).toBe(path.join(proj, 'a.txt'));157 expect(await resolveInside(config, proj, './sub//b.txt')).toBe(path.join(proj, 'sub', 'b.txt'));158 const r = await resolveInsideEx(config, proj, 'sub/b.txt');159 expect(r).toMatchObject({ rel: 'sub/b.txt', exists: true, rootReal: proj });160 });161 it('rejects traversal, absolute paths and NUL', async () => {162 expect(await code(resolveInside(config, proj, '../proj-evil/secret.txt'))).toBe('400:bad_path');163 expect(await code(resolveInside(config, proj, 'sub/../../proj-evil'))).toBe('400:bad_path');164 expect(await code(resolveInside(config, proj, 'sub\\..\\..\\x'))).toBe('400:bad_path');165 expect(await code(resolveInside(config, proj, path.join(outside, 'secret.txt')))).toBe('400:bad_path');166 expect(await code(resolveInside(config, proj, '/etc/passwd'))).toBe('400:bad_path');167 expect(await code(resolveInside(config, proj, 'a.txt\0.png'))).toBe('400:bad_path');168 });169 it('treats percent-encoded dots as a literal, harmless name', async () => {170 const r = await resolveInsideEx(config, proj, '%2e%2e/%2e%2e/outside/secret.txt');171 expect(r.abs).toBe(path.join(proj, '%2e%2e', '%2e%2e', 'outside', 'secret.txt'));172 expect(r.exists).toBe(false);173 expect(isInside(proj, r.abs)).toBe(true);174 expect((await resolveInsideEx(config, proj, '..%2fproj-evil')).abs).toBe(path.join(proj, '..%2fproj-evil'));175 });176 it('refuses a symlink (file) pointing outside the root', async () => {177 expect(await code(resolveInside(config, proj, 'link-file-out'))).toBe('403:path_outside_root');178 expect(await code(resolveInside(config, proj, 'link-file-out', { forWrite: true }))).toBe('403:path_outside_root');179 });180 it('refuses a symlink (directory) pointing outside, and anything below it', async () => {181 expect(await code(resolveInside(config, proj, 'link-dir-out'))).toBe('403:path_outside_root');182 expect(await code(resolveInside(config, proj, 'link-dir-out/x.txt'))).toBe('403:path_outside_root');183 expect(await code(resolveInside(config, proj, 'link-rel-out/secret.txt'))).toBe('403:path_outside_root');184 expect(await code(resolveInside(config, proj, 'link-sibling/secret.txt'))).toBe('403:path_outside_root');185 });186 it('refuses new files below a symlinked parent directory that leaves the root', async () => {187 expect(await code(resolveInside(config, proj, 'link-dir-out/new.txt', { forWrite: true }))).toBe('403:path_outside_root');188 expect(await code(resolveInside(config, proj, 'link-dir-out/a/b/c.txt', { forWrite: true }))).toBe('403:path_outside_root');189 expect(await code(resolveInside(config, proj, 'link-dir-out/new.txt', { forWrite: true, noFollowFinal: true }))).toBe(190 '403:path_outside_root',191 );192 });193 it('refuses symlink chains that end outside, follows chains that stay inside', async () => {194 expect(await code(resolveInside(config, proj, 'c1'))).toBe('403:path_outside_root');195 expect(await code(resolveInside(config, proj, 'c1/x.txt'))).toBe('403:path_outside_root');196 expect(await resolveInside(config, proj, 'i1/b.txt')).toBe(path.join(proj, 'sub', 'b.txt'));197 expect(await code(resolveInside(config, proj, 'loop-a'))).toBe('403:path_outside_root');198 expect(await code(resolveInside(config, proj, 'loop-a/x'))).toBe('403:path_outside_root');199 });200 it('follows symlinks that stay inside the root', async () => {201 expect(await resolveInside(config, proj, 'link-file-in')).toBe(path.join(proj, 'a.txt'));202 expect(await resolveInside(config, proj, 'link-dir-in/b.txt')).toBe(path.join(proj, 'sub', 'b.txt'));203 expect(await resolveInside(config, proj, 'link-dir-in/new.txt', { forWrite: true })).toBe(path.join(proj, 'sub', 'new.txt'));204 });205 it('never creates through a dangling symlink', async () => {206 expect(await code(resolveInside(config, proj, 'link-dangling-out', { forWrite: true }))).toBe('403:path_outside_root');207 expect(await code(resolveInside(config, proj, 'link-dangling-out'))).toBe('403:path_outside_root');208 expect(await code(resolveInside(config, proj, 'link-dangling-out/x/y', { forWrite: true }))).toBe('403:path_outside_root');209 // even a dangling link whose target would be inside is refused for follow-style access210 expect(await code(resolveInside(config, proj, 'link-dangling-in', { forWrite: true }))).toBe('403:path_outside_root');211 });212 it('noFollowFinal addresses the link itself; forWrite refuses links leaving the root', async () => {213 const r = await resolveInsideEx(config, proj, 'link-file-out', { noFollowFinal: true });214 expect(r.abs).toBe(path.join(proj, 'link-file-out'));215 expect(r.stats?.isSymbolicLink()).toBe(true);216 expect(await code(resolveInside(config, proj, 'link-file-out', { noFollowFinal: true, forWrite: true }))).toBe(217 '403:path_outside_root',218 );219 expect(await code(resolveInside(config, proj, 'link-dangling-out', { noFollowFinal: true, forWrite: true }))).toBe(220 '403:path_outside_root',221 );222 expect(await resolveInside(config, proj, 'link-file-in', { noFollowFinal: true, forWrite: true })).toBe(223 path.join(proj, 'link-file-in'),224 );225 const d = await resolveInsideEx(config, proj, 'link-dangling-out', { noFollowFinal: true });226 expect(d.exists).toBe(true); // can be listed / deleted as a link227 });228 it('resolves non-existent deep paths inside the root', async () => {229 const r = await resolveInsideEx(config, proj, 'sub/x/y/z/new.txt', { forWrite: true });230 expect(r.abs).toBe(path.join(proj, 'sub', 'x', 'y', 'z', 'new.txt'));231 expect(r.exists).toBe(false);232 expect(r.rel).toBe('sub/x/y/z/new.txt');233 const n = await resolveInsideEx(config, proj, 'q/w/e', { forWrite: true, noFollowFinal: true });234 expect(n.abs).toBe(path.join(proj, 'q', 'w', 'e'));235 expect(n.exists).toBe(false);236 });237 it('refuses paths that go through a regular file', async () => {238 expect(await code(resolveInside(config, proj, 'a.txt/child', { forWrite: true }))).toBe('400:not_a_directory');239 });240 it('does not confuse prefix-sibling directories with the root', async () => {241 expect(await code(resolveInside(config, proj, '../proj-evil/secret.txt'))).toBe('400:bad_path');242 expect(await code(resolveInside(config, proj, 'link-sibling'))).toBe('403:path_outside_root');243 // root itself being the sibling is fine only because it is inside the workspace root244 expect(await resolveInside(config, path.join(ws, 'proj-evil'), 'secret.txt')).toBe(path.join(ws, 'proj-evil', 'secret.txt'));245 const cfg = makeConfig([proj]);246 expect(await code(resolveInside(cfg, path.join(ws, 'proj-evil'), 'secret.txt'))).toBe('403:root_not_allowed');247 });248 it('refuses roots outside the workspace roots', async () => {249 expect(await code(resolveInside(config, outside, 'secret.txt'))).toBe('403:root_not_allowed');250 expect(await code(resolveInside(config, path.join(proj, 'link-dir-out'), 'x.txt'))).toBe('403:root_not_allowed');251 });252 it('unicode normalisation does not bypass the checks', async () => {253 // fullwidth / compatibility dots are refused outright254 expect(await code(resolveInside(config, proj, '\uFF0E\uFF0E/outside/secret.txt'))).toBe('400:bad_path');255 // the symlink is stored as NFC "café"; the NFD spelling reaches the same entry on APFS and256 // must be refused the same way (realpath-based containment, not string comparison)257 expect(await code(resolveInside(config, proj, 'caf\u00e9/x.txt'))).toBe('403:path_outside_root');258 const nfd = await code(resolveInside(config, proj, 'cafe\u0301/x.txt'));259 // normalisation-sensitive filesystems simply do not find the NFD name (a literal new path)260 expect(['403:path_outside_root', 'ok']).toContain(nfd);261 if (nfd === 'ok') {262 const abs = await resolveInside(config, proj, 'cafe\u0301/x.txt');263 expect(isInside(proj, abs)).toBe(true);264 expect(existsSync(abs)).toBe(false);265 }266 // zero-width / homoglyph names are plain literals inside the root267 const zw = await resolveInside(config, proj, '.\u200b./x');268 expect(isInside(proj, zw)).toBe(true);269 });270 it('refuses special files (FIFO, socket)', async () => {271 const fifo = path.join(proj, 'pipe');272 execFileSync('mkfifo', [fifo]);273 expect(await code(resolveInside(config, proj, 'pipe'))).toBe('400:special_file');274 expect(await code(resolveInside(config, proj, 'pipe', { noFollowFinal: true }))).toBe('400:special_file');275 const sock = path.join(proj, 's.sock');276 const server = net.createServer();277 await new Promise<void>((res) => server.listen(sock, res));278 try {279 expect(await code(resolveInside(config, proj, 's.sock', { forWrite: true }))).toBe('400:special_file');280 } finally {281 await new Promise<void>((res) => server.close(() => res()));282 }283 });284});285