From 9ff0b53ec678fa6ed28a406a149638940fe2278f Mon Sep 17 00:00:00 2001 From: Kyle P Date: Fri, 23 Oct 2026 19:20:42 -0700 Subject: [PATCH] fix(markdown): keep readable documents after traversal errors (#27774) * Keep readable Markdown documents after traversal errors * Document partial Markdown listing behavior --------- Co-authored-by: Neil --- .../markdown-documents-ripgrep-real.test.ts | 34 +++++++++++++- .../ipc/markdown-documents-ripgrep.test.ts | 47 +++++++++++++++---- src/main/ipc/markdown-documents.ts | 1 + 3 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/main/ipc/markdown-documents-ripgrep-real.test.ts b/src/main/ipc/markdown-documents-ripgrep-real.test.ts index 6549035a5d3..13216975acc 100644 --- a/src/main/ipc/markdown-documents-ripgrep-real.test.ts +++ b/src/main/ipc/markdown-documents-ripgrep-real.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, dirname, extname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -110,6 +110,38 @@ describe('Markdown listing with bundled ripgrep', () => { await expect(listMarkdownDocuments(root)).resolves.toEqual([]) }) + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'returns readable Markdown when ignored database directories deny access', + async () => { + await put('README.md') + await put('docs/guide.md') + await put('.gitignore', '/data/\n') + await put('data/clickhouse/store/hidden.md') + const store = join(root, 'data/clickhouse/store') + await chmod(store, 0o000) + try { + expect( + (await listMarkdownDocuments(root)).map((document) => document.relativePath) + ).toEqual(['docs/guide.md', 'README.md']) + } finally { + await chmod(store, 0o700) + } + } + ) + + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'still rejects an unreadable root', + async () => { + await put('README.md') + await chmod(root, 0o000) + try { + await expect(listMarkdownDocuments(root)).rejects.toThrow() + } finally { + await chmod(root, 0o700) + } + } + ) + it('rejects a missing root rather than treating it as an empty folder', async () => { await expect(listMarkdownDocuments(join(root, 'missing'))).rejects.toThrow() }) diff --git a/src/main/ipc/markdown-documents-ripgrep.test.ts b/src/main/ipc/markdown-documents-ripgrep.test.ts index c3810f68c7c..791f7bc1b64 100644 --- a/src/main/ipc/markdown-documents-ripgrep.test.ts +++ b/src/main/ipc/markdown-documents-ripgrep.test.ts @@ -60,9 +60,10 @@ describe('Markdown document ripgrep lifecycle', () => { it.each(['invalid', 'incomplete'] as const)('rejects %s UTF-8 filename bytes', async (kind) => { const result = listMarkdownDocuments(root) + child.stdout.write('./README.md\0') child.stdout.write(Buffer.from(kind === 'invalid' ? [0xff] : [0xe2, 0x82])) if (kind === 'incomplete') { - child.emit('close', 0, null) + child.emit('close', 2, null) } await expect(result).rejects.toThrow('not valid UTF-8') }) @@ -90,18 +91,47 @@ describe('Markdown document ripgrep lifecycle', () => { } ) - it('rejects an unreadable subtree even after receiving valid documents', async () => { + it('returns readable documents when an unrelated subtree is unreadable', async () => { const result = listMarkdownDocuments(root) - child.stdout.write('./README.md\0') - child.stderr.write('Permission denied') + child.stdout.write('./docs/guide.md\0./README.md\0') + child.stderr.write('rg: ./data/clickhouse/store: Permission denied (os error 13)') child.emit('close', 2, null) - await expect(result).rejects.toThrow('Permission denied') + expect((await result).map((document) => document.relativePath)).toEqual([ + 'docs/guide.md', + 'README.md' + ]) + expect(child.kill).not.toHaveBeenCalled() }) - it('rejects a truncated final path instead of returning partial documents', async () => { + it.each(['', './script.ts\0./.md\0'])( + 'still rejects an unreadable listing without valid Markdown documents: %j', + async (output) => { + const result = listMarkdownDocuments(root) + child.stdout.write(output) + child.stderr.write('rg: .: Permission denied (os error 13)') + child.emit('close', 2, null) + await expect(result).rejects.toThrow('Permission denied') + } + ) + + it('still rejects unexpected exit codes after receiving valid documents', async () => { + const result = listMarkdownDocuments(root) + child.stdout.write('./README.md\0') + child.emit('close', 3, null) + await expect(result).rejects.toThrow('Markdown document listing failed (3)') + }) + + it('still rejects a signalled process after receiving valid documents', async () => { + const result = listMarkdownDocuments(root) + child.stdout.write('./README.md\0') + child.emit('close', 2, 'SIGTERM') + await expect(result).rejects.toThrow('Markdown document listing failed (SIGTERM)') + }) + + it.each([0, 2])('rejects a truncated final path on exit %s', async (code) => { const result = listMarkdownDocuments(root) child.stdout.write('./README.md\0./unfinished.md') - child.emit('close', 0, null) + child.emit('close', code, null) await expect(result).rejects.toThrow('Incomplete path') }) @@ -109,6 +139,7 @@ describe('Markdown document ripgrep lifecycle', () => { 'rejects a path outside the relative listing protocol: %s', async (path) => { const result = listMarkdownDocuments(root) + child.stdout.write('./README.md\0') child.stdout.write(`${path}\0`) await expect(result).rejects.toThrow('Invalid path') expect(child.kill).toHaveBeenCalledWith('SIGKILL') @@ -215,7 +246,7 @@ it('rejects the 20,001st document without retaining the child', async () => { it('cancels the filtered producer and permits a fresh request', async () => { const controller = new AbortController() const result = listMarkdownDocuments(root, { signal: controller.signal }) - child.stdout.write('./partial') + child.stdout.write('./README.md\0./partial') controller.abort(new Error('editor closed')) await expect(result).rejects.toThrow('editor closed') expect(child.kill).toHaveBeenCalledWith('SIGKILL') diff --git a/src/main/ipc/markdown-documents.ts b/src/main/ipc/markdown-documents.ts index 6810fdb1fe9..7d44836c42f 100644 --- a/src/main/ipc/markdown-documents.ts +++ b/src/main/ipc/markdown-documents.ts @@ -23,6 +23,7 @@ export async function listMarkdownDocuments( stdio: ['ignore', 'pipe', 'pipe'] }) return collectMarkdownDocuments(child, rootPath, Boolean(distro), options.signal, { + allowPartialListing: true, stopProcess: () => stopBundledRipgrep(child, Boolean(distro)) }) }