mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(files): the review's fixes for the Files panel delete
The fixes #529's review asked for, each with a test. - No inline handler. The button quoted the path inside onclick="...decodeURIComponent('<path>')", and encodeURIComponent leaves ' alone, so a file named x'-alert(1)-'.txt ran script in Codeman's origin and it's.txt was a syntax error. It is now bound in the existing .file-tree-item loops from the row's escaped data-path. - Symbolic links are refused, never followed. validateSessionFilePath() returns the realpath, so deleting AGENTS.md -> CLAUDE.md deleted CLAUDE.md and left the link dangling. The path as named is lstat'ed, a link answers 400, and the inode is compared again before the unlink. - The edit route's confinement whole: validateLocalSessionFilePath() behind withGuardedOutsideWorktree(), so an absolute path in a followed outside worktree obeys the same rules, and the sensitive, blocked and .git checks run on both the realpath and the path as named. - Adopted sessions are refused like remote ones (the /tmp exception of the older file surfaces is not copied to a new one); Docker workspaces are allowed, as on the edit route. The workspace is probed bounded first (#516). Errors are thrown with their status. - Deleting from search results re-runs the search, the row buttons stay visible on touch screens, the confirm and toasts have zh-CN text, and no button is drawn for remote or adopted sessions. - Docs: CLAUDE.md, architecture-invariants, api-reference and the Working With Files wiki page. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
dfe1af2e5f
commit
078bd74bd1
12 files changed
+776
-37
No files matched your search
@@ -30,6 +30,9 @@ interface FakeRow {
|
||||
dataset: Record<string, string>;
|
||||
addEventListener: (type: string, handler: ClickHandler) => void;
|
||||
click: () => void;
|
||||
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
|
||||
// drives it over a real DOM).
|
||||
querySelector: (selector: string) => null;
|
||||
}
|
||||
|
||||
interface FakeElement {
|
||||
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
|
||||
click() {
|
||||
clickHandler?.();
|
||||
},
|
||||
querySelector: () => null,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
|
||||
*
|
||||
* #529 first rendered the button with an inline `onclick` that quoted the path inside
|
||||
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
|
||||
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
|
||||
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
|
||||
* bound from the row's escaped `data-path`.
|
||||
*
|
||||
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
|
||||
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
|
||||
* markup would really call `alert` here and this test would fail on it. The escaper is the real
|
||||
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
|
||||
* test/file-browser-incomplete.test.ts. Port: N/A.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
|
||||
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
|
||||
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
|
||||
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
|
||||
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
|
||||
|
||||
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
|
||||
const SESSION = 'sess-1';
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<div id="fileBrowserPanel" class="visible"></div>
|
||||
<input id="fileBrowserSearch">
|
||||
<div id="fileBrowserStatus"></div>
|
||||
<div id="fileBrowserTree"></div>`,
|
||||
{ runScripts: 'dangerously' }
|
||||
);
|
||||
afterAll(() => dom.window.close());
|
||||
const document = dom.window.document;
|
||||
const win = dom.window as unknown as Record<string, unknown>;
|
||||
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
|
||||
|
||||
interface FakeResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
const reply = (body: unknown, status = 200): FakeResponse => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
function loadPanel(
|
||||
options: {
|
||||
confirm?: (message: string) => boolean;
|
||||
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
|
||||
session?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
// The search debounce, collapsed so the re-run lands on the next turn.
|
||||
setTimeout: (fn: () => void) => setTimeout(fn, 0),
|
||||
clearTimeout,
|
||||
CodemanBase: { url: (p: string) => p },
|
||||
confirm: vi.fn(options.confirm ?? (() => true)),
|
||||
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
|
||||
});
|
||||
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
|
||||
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const app = new CodemanApp();
|
||||
app.$ = (id: string) => document.getElementById(id);
|
||||
app.activeSessionId = SESSION;
|
||||
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
|
||||
app.fileBrowserExpandedDirs = new Set(['src']);
|
||||
app.fileBrowserShowHidden = false;
|
||||
app.getFileIcon = () => '';
|
||||
app.formatFileSize = () => '';
|
||||
app.openFilePreview = vi.fn();
|
||||
app.showToast = vi.fn();
|
||||
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
|
||||
win.app = app;
|
||||
win.alert = vi.fn();
|
||||
return { app, context: context as Record<string, any> };
|
||||
}
|
||||
|
||||
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
|
||||
const treeEnvelope = (names: string[]) => ({
|
||||
success: true,
|
||||
data: {
|
||||
root: '/repo',
|
||||
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
|
||||
totalFiles: names.length,
|
||||
totalDirectories: 1,
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
const searchEnvelope = (names: string[]) => ({
|
||||
success: true,
|
||||
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
|
||||
});
|
||||
|
||||
const rowFor = (path: string) =>
|
||||
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
|
||||
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
|
||||
|
||||
function renderTree(app: Record<string, any>, names: string[]) {
|
||||
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
|
||||
app.renderFileBrowserTree(SESSION);
|
||||
}
|
||||
|
||||
function renderSearch(app: Record<string, any>, names: string[]) {
|
||||
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
|
||||
}
|
||||
|
||||
describe('Files panel delete button: no inline handler, the exact path', () => {
|
||||
it.each([
|
||||
['tree', renderTree],
|
||||
['search results', renderSearch],
|
||||
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
|
||||
const { app } = loadPanel();
|
||||
app.deleteFileFromBrowser = vi.fn();
|
||||
render(app, HOSTILE);
|
||||
|
||||
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
|
||||
for (const name of HOSTILE) {
|
||||
const button = deleteButtonFor(`src/${name}`);
|
||||
expect(button, name).toBeTruthy();
|
||||
expect(button!.getAttribute('onclick')).toBeNull();
|
||||
expect(button!.type).toBe('button');
|
||||
|
||||
button!.click();
|
||||
|
||||
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
|
||||
}
|
||||
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
|
||||
expect(win.alert).not.toHaveBeenCalled();
|
||||
// The click stays on the button: the row behind it does not open the file.
|
||||
expect(app.openFilePreview).not.toHaveBeenCalled();
|
||||
// The row itself still opens it.
|
||||
rowFor(`src/${HOSTILE[0]}`)!.click();
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
|
||||
});
|
||||
|
||||
it('labels the button with the escaped name, as text', () => {
|
||||
const { app } = loadPanel();
|
||||
renderTree(app, ['a "b" <i>c</i> & d.txt']);
|
||||
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
|
||||
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
|
||||
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
|
||||
});
|
||||
|
||||
it('offers no delete button on a folder row', () => {
|
||||
const { app } = loadPanel();
|
||||
renderTree(app, ['a.txt']);
|
||||
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
|
||||
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
|
||||
])('offers none for %s, which the route refuses', (_label, session) => {
|
||||
const { app } = loadPanel({ session });
|
||||
renderTree(app, ['a.txt']);
|
||||
expect(rowFor('src/a.txt')).toBeTruthy();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeNull();
|
||||
renderSearch(app, ['a.txt']);
|
||||
expect(rowFor('src/a.txt')).toBeTruthy();
|
||||
expect(deleteButtonFor('src/a.txt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteFileFromBrowser', () => {
|
||||
it('asks first and sends nothing when the answer is no', async () => {
|
||||
const { app, context } = loadPanel({ confirm: () => false });
|
||||
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
|
||||
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
|
||||
expect(context.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
|
||||
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
|
||||
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
|
||||
const path = "src/x'-alert(1)-' & #?.txt";
|
||||
|
||||
await app.deleteFileFromBrowser(SESSION, path);
|
||||
|
||||
expect(context.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
|
||||
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the server reason and reloads nothing when the delete is refused', async () => {
|
||||
const { app } = loadPanel({
|
||||
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
|
||||
});
|
||||
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
|
||||
|
||||
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
|
||||
|
||||
expect(app.showToast).toHaveBeenCalledWith(
|
||||
'Delete failed: Symbolic links are not deleted from the Files panel',
|
||||
'error'
|
||||
);
|
||||
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes the row from the search results it was deleted from', async () => {
|
||||
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
|
||||
const { app, context } = loadPanel({
|
||||
fetch: async (url, init) => {
|
||||
const query = new URL(url, 'http://localhost');
|
||||
if (init?.method === 'DELETE') {
|
||||
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
|
||||
files = files.filter((name) => name !== gone);
|
||||
return reply({ success: true, data: { path: query.searchParams.get('path') } });
|
||||
}
|
||||
const q = query.searchParams.get('q');
|
||||
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
|
||||
},
|
||||
});
|
||||
await app.loadFileBrowser(SESSION);
|
||||
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
|
||||
app.filterFileBrowser('me');
|
||||
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
|
||||
expect(rowFor('src/other.md')).toBeUndefined();
|
||||
|
||||
deleteButtonFor('src/delete-me.txt')!.click();
|
||||
|
||||
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
|
||||
expect(rowFor('src/keep-me.txt')).toBeTruthy();
|
||||
// Still the search view, not the tree behind it.
|
||||
expect(rowFor('src/other.md')).toBeUndefined();
|
||||
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
|
||||
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
|
||||
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
|
||||
);
|
||||
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Files panel delete button: touch screens', () => {
|
||||
it('keeps the row buttons visible where there is no hover', () => {
|
||||
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
|
||||
expect(
|
||||
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* @fileoverview `DELETE /api/sessions/:id/files?path=`, the Files panel's delete button (salvaged
|
||||
* from #529).
|
||||
*
|
||||
* Real temp workspace, no fs mock, like test/routes/file-write-routes.test.ts: the thing under
|
||||
* test is what the route does to real files, and the defect it fixes (a delete of
|
||||
* `AGENTS.md -> CLAUDE.md` removed CLAUDE.md, because the confinement check hands back the
|
||||
* realpath) only shows against a real symlink. Each refusal also checks that nothing on disk moved.
|
||||
*
|
||||
* Uses app.inject() — no real HTTP ports needed.
|
||||
* Port: N/A (app.inject doesn't open ports)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
|
||||
|
||||
describe('DELETE /api/sessions/:id/files (real fs)', () => {
|
||||
let harness: RouteTestHarness;
|
||||
let base: string;
|
||||
let workDir: string;
|
||||
let outsideDir: string;
|
||||
|
||||
const del = (path: string, h: RouteTestHarness = harness) =>
|
||||
h.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${h.ctx._sessionId}/files?path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
// realpath: tmpdir() can hold a symlinked component (macOS /tmp), and the fixtures compare paths.
|
||||
base = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-delete-')));
|
||||
workDir = join(base, 'ws');
|
||||
outsideDir = join(base, 'outside');
|
||||
mkdirSync(workDir);
|
||||
mkdirSync(outsideDir);
|
||||
harness.ctx._session.workingDir = workDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
rmSync(SETTINGS_PATH, { force: true });
|
||||
});
|
||||
|
||||
it('deletes a regular file and answers the path it was given', async () => {
|
||||
mkdirSync(join(workDir, 'src'));
|
||||
writeFileSync(join(workDir, 'src', 'old.ts'), 'x');
|
||||
writeFileSync(join(workDir, 'keep.ts'), 'y');
|
||||
|
||||
const res = await del('src/old.ts');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ success: true, data: { path: 'src/old.ts' } });
|
||||
expect(existsSync(join(workDir, 'src', 'old.ts'))).toBe(false);
|
||||
expect(existsSync(join(workDir, 'src'))).toBe(true);
|
||||
expect(readFileSync(join(workDir, 'keep.ts'), 'utf8')).toBe('y');
|
||||
});
|
||||
|
||||
it('deletes a file whose name carries quotes and other characters a shell or a URL cares about', async () => {
|
||||
const name = "x'-alert(1)-' & #?.txt";
|
||||
writeFileSync(join(workDir, name), 'x');
|
||||
|
||||
const res = await del(name);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(workDir, name))).toBe(false);
|
||||
});
|
||||
|
||||
describe('symbolic links are refused, never followed', () => {
|
||||
it('leaves both the link and its target when asked to delete AGENTS.md -> CLAUDE.md', async () => {
|
||||
writeFileSync(join(workDir, 'CLAUDE.md'), '# rules');
|
||||
symlinkSync('CLAUDE.md', join(workDir, 'AGENTS.md'));
|
||||
|
||||
const res = await del('AGENTS.md');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().success).toBe(false);
|
||||
expect(res.json().error).toMatch(/symbolic link/i);
|
||||
expect(readFileSync(join(workDir, 'CLAUDE.md'), 'utf8')).toBe('# rules');
|
||||
expect(lstatSync(join(workDir, 'AGENTS.md')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a link to a folder, which the listing shows as a file row', async () => {
|
||||
mkdirSync(join(workDir, 'real'));
|
||||
writeFileSync(join(workDir, 'real', 'a.txt'), 'a');
|
||||
symlinkSync('real', join(workDir, 'alias'));
|
||||
|
||||
const res = await del('alias');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'real', 'a.txt'), 'utf8')).toBe('a');
|
||||
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('answers 404 for a link that leads out of the workspace, and leaves its target', async () => {
|
||||
writeFileSync(join(outsideDir, 'secret.txt'), 'secret');
|
||||
symlinkSync(join(outsideDir, 'secret.txt'), join(workDir, 'leak.txt'));
|
||||
|
||||
const res = await del('leak.txt');
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(outsideDir, 'secret.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
|
||||
it('deletes a plain file reached through a linked folder inside the workspace', async () => {
|
||||
mkdirSync(join(workDir, 'real'));
|
||||
writeFileSync(join(workDir, 'real', 'b.txt'), 'b');
|
||||
symlinkSync('real', join(workDir, 'alias'));
|
||||
|
||||
const res = await del('alias/b.txt');
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(workDir, 'real', 'b.txt'))).toBe(false);
|
||||
expect(lstatSync(join(workDir, 'alias')).isSymbolicLink()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses a file under .git with 403, also when .git is reached through a linked folder', async () => {
|
||||
mkdirSync(join(workDir, '.git'));
|
||||
writeFileSync(join(workDir, '.git', 'config'), '[core]');
|
||||
symlinkSync('.git', join(workDir, 'gitdir'));
|
||||
|
||||
for (const path of ['.git/config', 'gitdir/config']) {
|
||||
const res = await del(path);
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().success).toBe(false);
|
||||
}
|
||||
expect(readFileSync(join(workDir, '.git', 'config'), 'utf8')).toBe('[core]');
|
||||
});
|
||||
|
||||
it('refuses a sensitive file with 403', async () => {
|
||||
writeFileSync(join(workDir, '.env'), 'TOKEN=abc');
|
||||
|
||||
const res = await del('.env');
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(readFileSync(join(workDir, '.env'), 'utf8')).toBe('TOKEN=abc');
|
||||
});
|
||||
|
||||
it('answers 404 for a path that climbs out of the workspace', async () => {
|
||||
writeFileSync(join(outsideDir, 'victim.txt'), 'v');
|
||||
|
||||
for (const path of ['../outside/victim.txt', join(outsideDir, 'victim.txt')]) {
|
||||
const res = await del(path);
|
||||
expect(res.statusCode).toBe(404);
|
||||
}
|
||||
expect(readFileSync(join(outsideDir, 'victim.txt'), 'utf8')).toBe('v');
|
||||
});
|
||||
|
||||
it('answers 404 for a file that does not exist', async () => {
|
||||
expect((await del('nope.txt')).statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('refuses a directory with 400', async () => {
|
||||
mkdirSync(join(workDir, 'src'));
|
||||
writeFileSync(join(workDir, 'src', 'a.ts'), 'a');
|
||||
|
||||
const res = await del('src');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readFileSync(join(workDir, 'src', 'a.ts'), 'utf8')).toBe('a');
|
||||
});
|
||||
|
||||
it('refuses a missing or repeated path parameter with 400', async () => {
|
||||
const missing = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files`,
|
||||
});
|
||||
expect(missing.statusCode).toBe(400);
|
||||
writeFileSync(join(workDir, 'a.txt'), 'a');
|
||||
const repeated = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?path=a.txt&path=a.txt`,
|
||||
});
|
||||
expect(repeated.statusCode).toBe(400);
|
||||
expect(existsSync(join(workDir, 'a.txt'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a remote (SSH) session before looking at the local twin of its folder', async () => {
|
||||
// The local folder of the same absolute name is exactly the hazard: validation would resolve
|
||||
// against it and the delete would land here while the user meant the remote file.
|
||||
writeFileSync(join(workDir, 'twin.txt'), 'local');
|
||||
harness.ctx._session.remote = {
|
||||
hostId: 'host-1',
|
||||
label: 'testhost',
|
||||
host: '192.0.2.10',
|
||||
username: 'j',
|
||||
remotePath: workDir,
|
||||
};
|
||||
|
||||
const res = await del('twin.txt');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/remote/i);
|
||||
expect(readFileSync(join(workDir, 'twin.txt'), 'utf8')).toBe('local');
|
||||
});
|
||||
|
||||
it('refuses an adopted session, whose folder is not a workspace of ours', async () => {
|
||||
writeFileSync(join(workDir, 'shared.txt'), 'tmp');
|
||||
harness.ctx._session.isAdopted = true;
|
||||
|
||||
const res = await del('shared.txt');
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/adopted/i);
|
||||
expect(readFileSync(join(workDir, 'shared.txt'), 'utf8')).toBe('tmp');
|
||||
});
|
||||
|
||||
describe('a worktree outside the session folder', () => {
|
||||
let sibling: string;
|
||||
|
||||
beforeEach(() => {
|
||||
sibling = join(base, 'ws-feature');
|
||||
mkdirSync(join(sibling, 'src'), { recursive: true });
|
||||
writeFileSync(join(sibling, 'src', 'feature.ts'), 'feature');
|
||||
Object.assign(harness.ctx._session, {
|
||||
liveWorktree: { name: 'ws-feature', path: sibling, outside: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes an absolute path inside it, as the other file routes serve it', async () => {
|
||||
const res = await del(join(sibling, 'src', 'feature.ts'));
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not reach it with a relative path, which resolves against the session folder only', async () => {
|
||||
const res = await del('../ws-feature/src/feature.ts');
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses a symlink in it that leads out of it, and leaves the target', async () => {
|
||||
writeFileSync(join(outsideDir, 'key.txt'), 'secret');
|
||||
symlinkSync(join(outsideDir, 'key.txt'), join(sibling, 'leak.txt'));
|
||||
|
||||
const res = await del(join(sibling, 'leak.txt'));
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(outsideDir, 'key.txt'), 'utf8')).toBe('secret');
|
||||
});
|
||||
|
||||
it('reaches nothing there once it is no longer an outside worktree', async () => {
|
||||
Object.assign(harness.ctx._session, { liveWorktree: { name: 'ws-feature', path: sibling } });
|
||||
expect((await del(join(sibling, 'src', 'feature.ts'))).statusCode).toBe(404);
|
||||
expect(existsSync(join(sibling, 'src', 'feature.ts'))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['workspace confinement is turned on', () => ({ attachmentConfineToWorkspace: true })],
|
||||
['its parent folder is blocked', () => ({ attachmentBlockedPaths: [base] })],
|
||||
])('deletes nothing there once %s', async (_label, settings) => {
|
||||
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify(settings()));
|
||||
|
||||
const res = await del(join(sibling, 'src', 'feature.ts'));
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(sibling, 'src', 'feature.ts'), 'utf8')).toBe('feature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user mode', () => {
|
||||
let prevMultiUser: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
prevMultiUser = process.env.CODEMAN_MULTIUSER;
|
||||
process.env.CODEMAN_MULTIUSER = '1';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
|
||||
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
|
||||
});
|
||||
|
||||
it("answers 404 for another user's session and deletes nothing", async () => {
|
||||
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
|
||||
try {
|
||||
bob.ctx._session.workingDir = workDir;
|
||||
(bob.ctx._session as unknown as { owner?: string }).owner = 'alice';
|
||||
writeFileSync(join(workDir, 'alice.txt'), 'mine');
|
||||
|
||||
const res = await del('alice.txt', bob);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(readFileSync(join(workDir, 'alice.txt'), 'utf8')).toBe('mine');
|
||||
} finally {
|
||||
await bob.app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('lets the owner delete in their own session', async () => {
|
||||
const bob = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
|
||||
try {
|
||||
bob.ctx._session.workingDir = workDir;
|
||||
(bob.ctx._session as unknown as { owner?: string }).owner = 'bob';
|
||||
writeFileSync(join(workDir, 'bob.txt'), 'mine');
|
||||
|
||||
expect((await del('bob.txt', bob)).statusCode).toBe(200);
|
||||
expect(existsSync(join(workDir, 'bob.txt'))).toBe(false);
|
||||
} finally {
|
||||
await bob.app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user