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 <neil@stably.ai>
This commit is contained in:
Kyle P
2026-10-23 19:20:42 -07:00
committed by GitHub
co-authored by Neil
parent 221eb1ce8b
commit 9ff0b53ec6
3 changed files with 73 additions and 9 deletions
@@ -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()
})
@@ -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')
+1
View File
@@ -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))
})
}