Repository Public
1import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs';2import path from 'node:path';3import { afterEach, beforeEach, describe, expect, it } from 'vitest';4import { PathError, protectedReason, resolveInWorkspace } from './paths.js';5import { makeFixture, type Fixture } from './testutil.js';67let fx: Fixture;8beforeEach(() => {9 fx = makeFixture();10});11afterEach(() => fx.cleanup());1213async function codeOf(p: string, mode: 'read' | 'write'): Promise<string> {14 try {15 await resolveInWorkspace(fx.ws, p, mode);16 return 'ok';17 } catch (e) {18 if (e instanceof PathError) return e.code;19 throw e;20 }21}2223describe('resolveInWorkspace', () => {24 it('resolves relative paths, existing or not', async () => {25 writeFileSync(path.join(fx.ws, 'a.txt'), 'x');26 const a = await resolveInWorkspace(fx.ws, 'a.txt', 'read');27 expect(a).toMatchObject({ abs: path.join(fx.ws, 'a.txt'), rel: 'a.txt', exists: true });28 const b = await resolveInWorkspace(fx.ws, 'new/dir/b.txt', 'write');29 expect(b).toMatchObject({ abs: path.join(fx.ws, 'new/dir/b.txt'), rel: 'new/dir/b.txt', exists: false });30 expect((await resolveInWorkspace(fx.ws, '.', 'read')).rel).toBe('');31 expect((await resolveInWorkspace(fx.ws, './a.txt', 'read')).rel).toBe('a.txt');32 expect((await resolveInWorkspace(fx.ws, 'sub//x.txt', 'write')).rel).toBe('sub/x.txt');33 });3435 it('accepts absolute paths inside the workspace only', async () => {36 expect((await resolveInWorkspace(fx.ws, path.join(fx.ws, 'x/y.txt'), 'write')).rel).toBe('x/y.txt');37 expect(await codeOf('/etc/passwd', 'read')).toBe('outside');38 expect(await codeOf(path.join(fx.root, 'data', 'x'), 'read')).toBe('outside');39 expect(await codeOf(`${fx.ws}-sibling/x`, 'write')).toBe('outside'); // prefix trick40 expect(await codeOf('~/x', 'write')).toBe('ok'); // literal directory named "~" inside the workspace, not $HOME41 expect((await resolveInWorkspace(fx.ws, '~/x', 'write')).abs).toBe(path.join(fx.ws, '~/x'));42 });4344 it('accepts a workspace given through a symlinked path (/tmp vs /private/tmp style)', async () => {45 const link = path.join(fx.root, 'ws-link');46 symlinkSync(fx.ws, link);47 const r = await resolveInWorkspace(link, path.join(link, 'a.txt'), 'write');48 expect(r.abs).toBe(path.join(fx.ws, 'a.txt'));49 expect(r.workspaceReal).toBe(fx.ws);50 });5152 it('rejects "..", NUL, empty and oversized paths', async () => {53 expect(await codeOf('../x', 'read')).toBe('invalid');54 expect(await codeOf('a/../../x', 'read')).toBe('invalid');55 expect(await codeOf('a/../b', 'read')).toBe('invalid'); // even when it would stay inside56 expect(await codeOf('a\\..\\b', 'read')).toBe('invalid');57 expect(await codeOf('a\0b', 'read')).toBe('invalid');58 expect(await codeOf('', 'read')).toBe('invalid');59 expect(await codeOf('a'.repeat(5000), 'read')).toBe('invalid');60 expect(await codeOf('a..b/c...d', 'write')).toBe('ok'); // dots inside names are fine61 });6263 it('refuses symlink escapes for reads and writes', async () => {64 const outside = path.join(fx.root, 'outside');65 mkdirSync(outside);66 writeFileSync(path.join(outside, 'secret.txt'), 's');67 symlinkSync(path.join(outside, 'secret.txt'), path.join(fx.ws, 'link-file'));68 symlinkSync(outside, path.join(fx.ws, 'link-dir'));69 symlinkSync(path.join(outside, 'does-not-exist'), path.join(fx.ws, 'dangling'));70 symlinkSync('loop-b', path.join(fx.ws, 'loop-a'));71 symlinkSync('loop-a', path.join(fx.ws, 'loop-b'));72 for (const mode of ['read', 'write'] as const) {73 expect(await codeOf('link-file', mode)).toBe('symlink_escape');74 expect(await codeOf('link-dir/secret.txt', mode)).toBe('symlink_escape');75 expect(await codeOf('link-dir/new.txt', mode)).toBe('symlink_escape');76 expect(await codeOf('link-dir/deep/er/new.txt', mode)).toBe('symlink_escape');77 expect(await codeOf('dangling', mode)).toBe('symlink_escape');78 expect(await codeOf('dangling/x', mode)).toBe('symlink_escape');79 expect(await codeOf('loop-a', mode)).toBe('symlink_escape');80 }81 });8283 it('follows symlinks that stay inside the workspace, keyed by the real path', async () => {84 mkdirSync(path.join(fx.ws, 'real'));85 writeFileSync(path.join(fx.ws, 'real', 'f.txt'), 'x');86 symlinkSync('real', path.join(fx.ws, 'alias'));87 const r = await resolveInWorkspace(fx.ws, 'alias/f.txt', 'write');88 expect(r.rel).toBe('real/f.txt');89 });9091 it('never lets a symlink alias reach a protected path', async () => {92 mkdirSync(path.join(fx.ws, '.git'));93 writeFileSync(path.join(fx.ws, '.git', 'config'), '');94 symlinkSync('.git', path.join(fx.ws, 'innocent'));95 symlinkSync('.git/config', path.join(fx.ws, 'cfg'));96 expect(await codeOf('innocent/config', 'write')).toBe('protected');97 expect(await codeOf('innocent/hooks/pre-commit', 'write')).toBe('protected');98 expect(await codeOf('cfg', 'write')).toBe('protected');99 expect(await codeOf('innocent/config', 'read')).toBe('ok');100 });101102 it('protected paths are refused for writes and allowed for reads', async () => {103 const protectedPaths = [104 '.git', '.git/config', '.git/hooks/pre-commit', '.git/HEAD', '.git/info/attributes', '.GIT/config', '.Git/hooks/x',105 'sub/.git/config', 'vendor/lib/.git/hooks/post-checkout',106 '.aos', '.aos/x', '.AOS/state.json', '.aos/deep/er/file',107 'AGENTS.md', 'CLAUDE.md', 'agents.md', 'Claude.MD',108 '.zshrc', '.zshenv', '.zprofile', '.bashrc', '.bash_profile', '.profile', '.envrc', 'sub/.zshrc', 'a/b/.BASHRC',109 '.git/config', // HFS+-ignorable code point110 '.',111 ];112 for (const p of protectedPaths) {113 expect(await codeOf(p, 'write'), p).toBe('protected');114 expect(await codeOf(p, 'read'), p).toBe('ok');115 }116 const fine = ['docs/AGENTS.md', 'sub/CLAUDE.md', '.gitignore', '.github/workflows/ci.yml', 'gitx/.gitkeep', 'my.zshrc', '.aosx/file', 'src/.env', '.gitattributes', 'AGENTS.md.bak'];117 for (const p of fine) expect(await codeOf(p, 'write'), p).toBe('ok');118 });119120 it('protectedReason is null for ordinary paths', () => {121 expect(protectedReason('src/index.ts')).toBeNull();122 expect(protectedReason('.git/config')).toMatch(/git tools/);123 expect(protectedReason('')).not.toBeNull();124 });125126 it('fails cleanly when the workspace does not exist', async () => {127 await expect(resolveInWorkspace(path.join(fx.root, 'nope'), 'a', 'read')).rejects.toBeInstanceOf(PathError);128 await expect(resolveInWorkspace('relative/ws', 'a', 'read')).rejects.toBeInstanceOf(PathError);129 });130});131