Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions packages/api/src/__tests__/fs-browse.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
mkdtemp,
mkdir,
realpath,
rm,
symlink,
writeFile,
} from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
DIRECTORY_ENTRY_LIMIT,
browseDirectories,
resolveBrowseRoots,
} from '../fs-browse';

const temporaryDirectories: string[] = [];

async function makeTemporaryDirectory(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}

afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});

describe('resolveBrowseRoots', () => {
it('returns the home directory plus normalized absolute configured roots', async () => {
const home = await makeTemporaryDirectory('codegraph-home-');
const configured = await makeTemporaryDirectory('codegraph-root-');

await expect(
resolveBrowseRoots(home, ` ${configured},relative,${home} `),
).resolves.toEqual([await realpath(home), await realpath(configured)]);
});
});

describe('browseDirectories', () => {
it('returns configured roots when no path is requested', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');

await expect(browseDirectories(undefined, [root])).resolves.toEqual({
path: null,
parent: null,
entries: [
{
name: root.split('/').at(-1),
path: await realpath(root),
projectMarkers: [],
isSymlink: false,
},
],
truncated: false,
});
});

it('lists only immediate child directories with sorted project markers', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
const alpha = join(root, 'alpha');
const beta = join(root, 'beta');
await mkdir(alpha);
await mkdir(beta);
await mkdir(join(alpha, '.git'));
await writeFile(join(alpha, 'package.json'), '{}');
await writeFile(join(alpha, 'README.md'), 'not exposed');
await writeFile(join(root, 'root-file.txt'), 'not exposed');

await expect(browseDirectories(root, [root])).resolves.toEqual({
path: await realpath(root),
parent: null,
entries: [
{
name: 'alpha',
path: await realpath(alpha),
projectMarkers: ['.git', 'package.json'],
isSymlink: false,
},
{
name: 'beta',
path: await realpath(beta),
projectMarkers: [],
isSymlink: false,
},
],
truncated: false,
});
});

it('returns the containing browse root as parent for a child directory', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
const child = join(root, 'child');
await mkdir(child);

const result = await browseDirectories(child, [root]);

expect(result.path).toBe(await realpath(child));
expect(result.parent).toBe(await realpath(root));
});

it('omits hidden directories unless includeHidden is true', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
await mkdir(join(root, '.hidden'));
await mkdir(join(root, 'visible'));

const hiddenByDefault = await browseDirectories(root, [root]);
const hiddenIncluded = await browseDirectories(root, [root], {
includeHidden: true,
});

expect(hiddenByDefault.entries.map((entry) => entry.name)).toEqual([
'visible',
]);
expect(hiddenIncluded.entries.map((entry) => entry.name)).toEqual([
'.hidden',
'visible',
]);
});

it('rejects lexical and encoded traversal outside a browse root with 403', async () => {
const container = await makeTemporaryDirectory('codegraph-container-');
const root = join(container, 'root');
const outside = join(container, 'outside');
await mkdir(root);
await mkdir(outside);

await expect(
browseDirectories(join(root, '..', 'outside'), [root]),
).rejects.toMatchObject({
status: 403,
});
await expect(
browseDirectories(decodeURIComponent(`${root}/%2e%2e/outside`), [root]),
).rejects.toMatchObject({ status: 403 });
});

it('rejects a symlink to a directory outside a browse root with 403', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
const outside = await makeTemporaryDirectory('codegraph-outside-');
const link = join(root, 'outside-link');
await symlink(outside, link, 'dir');

await expect(browseDirectories(link, [root])).rejects.toMatchObject({
status: 403,
});
});

it('marks in-root directory symlinks and omits out-of-root symlinks', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
const outside = await makeTemporaryDirectory('codegraph-outside-');
const target = join(root, 'target');
await mkdir(target);
await symlink(target, join(root, 'inside-link'), 'dir');
await symlink(outside, join(root, 'outside-link'), 'dir');

const result = await browseDirectories(root, [root]);
const normalizedRoot = await realpath(root);

expect(result.entries).toEqual([
{
name: 'inside-link',
path: join(normalizedRoot, 'inside-link'),
projectMarkers: [],
isSymlink: true,
},
{
name: 'target',
path: join(normalizedRoot, 'target'),
projectMarkers: [],
isSymlink: false,
},
]);
});

it('returns 404 for a nonexistent path', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');

await expect(
browseDirectories(join(root, 'missing'), [root]),
).rejects.toMatchObject({
status: 404,
});
});

it('returns 400 for a file path and for a relative path', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
const file = join(root, 'file.txt');
await writeFile(file, 'file');

await expect(browseDirectories(file, [root])).rejects.toMatchObject({
status: 400,
});
await expect(
browseDirectories('relative/path', [root]),
).rejects.toMatchObject({
status: 400,
});
});

it('caps sorted entries and reports truncation', async () => {
const root = await makeTemporaryDirectory('codegraph-root-');
await Promise.all(
Array.from({ length: DIRECTORY_ENTRY_LIMIT + 1 }, (_, index) =>
mkdir(join(root, `directory-${String(index).padStart(3, '0')}`)),
),
);

const result = await browseDirectories(root, [root]);

expect(result.entries).toHaveLength(DIRECTORY_ENTRY_LIMIT);
expect(result.entries[0]?.name).toBe('directory-000');
expect(result.entries.at(-1)?.name).toBe(
`directory-${String(DIRECTORY_ENTRY_LIMIT - 1).padStart(3, '0')}`,
);
expect(result.truncated).toBe(true);
});
});
129 changes: 129 additions & 0 deletions packages/api/src/__tests__/fs-directories-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createFsRoutes } from '../routes/fs-directories';

const temporaryDirectories: string[] = [];

async function makeTemporaryDirectory(prefix: string): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), prefix));
temporaryDirectories.push(directory);
return directory;
}

afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});

describe('GET /api/fs/directories', () => {
it('returns a requested directory and honors includeHidden=true', async () => {
const root = await makeTemporaryDirectory('codegraph-route-root-');
await mkdir(join(root, '.hidden'));
await mkdir(join(root, 'visible'));
const routes = createFsRoutes({ homeDirectory: root });
const query = new URLSearchParams({ path: root, includeHidden: 'true' });

const response = await routes.request(
`/api/fs/directories?${query.toString()}`,
);

expect(response.status).toBe(200);
const body = (await response.json()) as {
path: string | null;
parent: string | null;
entries: Array<{ name: string }>;
truncated: boolean;
};
expect(body.path).not.toBeNull();
expect(body.parent).toBeNull();
expect(body.entries.map((entry) => entry.name)).toEqual([
'.hidden',
'visible',
]);
expect(body.truncated).toBe(false);
});

it('returns configured roots when path is absent', async () => {
const home = await makeTemporaryDirectory('codegraph-route-home-');
const extra = await makeTemporaryDirectory('codegraph-route-extra-');
const routes = createFsRoutes({
homeDirectory: home,
configuredRoots: extra,
});

const response = await routes.request('/api/fs/directories');

expect(response.status).toBe(200);
const body = (await response.json()) as {
path: string | null;
parent: string | null;
entries: Array<{ path: string }>;
truncated: boolean;
};
expect(body.path).toBeNull();
expect(body.parent).toBeNull();
expect(body.entries).toHaveLength(2);
expect(body.entries.map((entry) => entry.path)).toEqual(
expect.arrayContaining([
expect.stringContaining(home.split('/').at(-1) ?? ''),
]),
);
expect(body.truncated).toBe(false);
});

it('returns 403 for an encoded traversal outside the root', async () => {
const container = await makeTemporaryDirectory(
'codegraph-route-container-',
);
const root = join(container, 'root');
const outside = join(container, 'outside');
await mkdir(root);
await mkdir(outside);
const routes = createFsRoutes({ homeDirectory: root });
const encodedPath = `${encodeURIComponent(root)}%2F%2E%2E%2Foutside`;

const response = await routes.request(
`/api/fs/directories?path=${encodedPath}`,
);

expect(response.status).toBe(403);
await expect(response.json()).resolves.toEqual({
error: 'path is outside every filesystem browse root',
});
});

it('does not register a mutating method', async () => {
const root = await makeTemporaryDirectory('codegraph-route-root-');
const routes = createFsRoutes({ homeDirectory: root });

const response = await routes.request('/api/fs/directories', {
method: 'POST',
});

expect(response.status).toBe(404);
});

it('returns a fixed message when directory enumeration fails unexpectedly', async () => {
const root = await makeTemporaryDirectory('codegraph-route-root-');
const routes = createFsRoutes({
homeDirectory: root,
browse: async () => {
throw new Error('sensitive filesystem detail');
},
});

const response = await routes.request(
`/api/fs/directories?path=${encodeURIComponent(root)}`,
);

expect(response.status).toBe(500);
await expect(response.json()).resolves.toEqual({
error: 'Failed to browse directories.',
});
});
});
Loading
Loading