SPB Git

Repository Public

paths.test.ts 6.4 KB 131 lines typescript

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      '.g‌it/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
Files main
Menu

Theme