483 lines
16 KiB
TypeScript
483 lines
16 KiB
TypeScript
import assert from 'node:assert/strict'
|
|
import { EventEmitter } from 'node:events'
|
|
import path from 'node:path'
|
|
|
|
import { test } from 'vitest'
|
|
|
|
import { pathForRegistryBackendRequest } from './connection-config'
|
|
import type { PumpDeps } from './gateway-file-download'
|
|
import {
|
|
downloadTempPath,
|
|
filenameFromContentDisposition,
|
|
gatewayFilePath,
|
|
gatewayFileRequestPaths,
|
|
isNotFoundError,
|
|
parseDataUrlToBuffer,
|
|
pumpStreamToFile,
|
|
resolveGatewayFileBackend,
|
|
writeBufferToFile
|
|
} from './gateway-file-download'
|
|
|
|
// A Readable-like response driven manually in tests.
|
|
class FakeResponse extends EventEmitter {
|
|
paused = false
|
|
resumed = false
|
|
destroyed = false
|
|
|
|
pause() {
|
|
this.paused = true
|
|
}
|
|
|
|
resume() {
|
|
this.resumed = true
|
|
}
|
|
|
|
destroy() {
|
|
this.destroyed = true
|
|
}
|
|
}
|
|
|
|
// A write stream that records writes and lets tests control backpressure.
|
|
class FakeWriteStream extends EventEmitter {
|
|
chunks: Buffer[] = []
|
|
ended = false
|
|
destroyed = false
|
|
private writeReturns: boolean[]
|
|
|
|
constructor(writeReturns: boolean[] = [], { opens = true }: { opens?: boolean } = {}) {
|
|
super()
|
|
this.writeReturns = writeReturns
|
|
|
|
// Like fs.WriteStream: 'open' fires once the exclusive create succeeded.
|
|
// `opens: false` models a create that fails before any file exists.
|
|
if (opens) {
|
|
queueMicrotask(() => this.emit('open'))
|
|
}
|
|
}
|
|
|
|
write(chunk: Buffer): boolean {
|
|
this.chunks.push(chunk)
|
|
|
|
return this.writeReturns.length ? this.writeReturns.shift()! : true
|
|
}
|
|
|
|
end(cb: () => void) {
|
|
this.ended = true
|
|
cb()
|
|
}
|
|
|
|
// Like fs.WriteStream: the descriptor is released asynchronously and 'close'
|
|
// fires afterwards.
|
|
destroy() {
|
|
this.destroyed = true
|
|
queueMicrotask(() => this.emit('close'))
|
|
}
|
|
}
|
|
|
|
// Deps recorder shared by the pumpStreamToFile tests: captures every path the
|
|
// pump opens, renames, or unlinks so each test can assert the destination itself
|
|
// was never touched before the body finished.
|
|
function recordingDeps(ws: FakeWriteStream, { renameError }: { renameError?: Error } = {}) {
|
|
const opened: string[] = []
|
|
const renamed: Array<[string, string]> = []
|
|
const unlinked: string[] = []
|
|
|
|
const deps: PumpDeps = {
|
|
createWriteStream: (p: string) => {
|
|
opened.push(p)
|
|
|
|
return ws as never
|
|
},
|
|
rename: async (from: string, to: string) => {
|
|
if (renameError) {
|
|
throw renameError
|
|
}
|
|
|
|
renamed.push([from, to])
|
|
},
|
|
unlink: async (p: string) => {
|
|
unlinked.push(p)
|
|
}
|
|
}
|
|
|
|
return { deps, opened, renamed, unlinked }
|
|
}
|
|
|
|
// Separator-agnostic: path.join emits backslashes on Windows, so the expectation
|
|
// is "short hidden .part name, same directory as the destination", not a
|
|
// literal POSIX string.
|
|
const TEMP_BASENAME = /^\.hermes-download-[0-9a-f]{8}\.part$/
|
|
|
|
// path.join normalizes separators (``/tmp`` -> ``\\tmp`` on Windows) while the
|
|
// literal destination strings in these tests do not, so compare normalized forms.
|
|
function assertTempPathBeside(tempPath: string, destPath: string) {
|
|
assert.equal(
|
|
path.normalize(path.dirname(tempPath)),
|
|
path.normalize(path.dirname(destPath)),
|
|
'temp file must sit beside the destination'
|
|
)
|
|
assert.match(path.basename(tempPath), TEMP_BASENAME)
|
|
}
|
|
|
|
test('downloadTempPath stays beside the destination with a short, random per-call name', () => {
|
|
const a = downloadTempPath('/tmp/out.bin')
|
|
const b = downloadTempPath('/tmp/out.bin')
|
|
|
|
assertTempPathBeside(a, '/tmp/out.bin')
|
|
assertTempPathBeside(b, '/tmp/out.bin')
|
|
assert.notEqual(a, b, 'two concurrent saves into the same directory must not share a temp file')
|
|
|
|
// The temp name must not grow with the user's filename: a destination near the
|
|
// filesystem's name limit still gets a temp file that fits beside it.
|
|
const longName = `/downloads/${'x'.repeat(250)}.bin`
|
|
|
|
assert.equal(path.normalize(path.dirname(downloadTempPath(longName))), path.normalize(path.dirname(longName)))
|
|
assert.ok(path.basename(downloadTempPath(longName)).length < 40)
|
|
})
|
|
|
|
test('pumpStreamToFile streams chunks into a sibling temp file, then renames it onto the destination', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream()
|
|
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
res.emit('data', Buffer.from('def'))
|
|
res.emit('end')
|
|
|
|
await promise
|
|
|
|
assert.equal(Buffer.concat(ws.chunks).toString('utf8'), 'abcdef')
|
|
assert.equal(ws.ended, true)
|
|
assert.equal(opened.length, 1)
|
|
assertTempPathBeside(opened[0], '/tmp/out.bin')
|
|
assert.deepEqual(renamed, [[opened[0], '/tmp/out.bin']])
|
|
assert.deepEqual(unlinked, []) // success -> no cleanup
|
|
})
|
|
|
|
test('pumpStreamToFile waits for the descriptor to close before renaming when the stream supports close()', async () => {
|
|
const res = new FakeResponse()
|
|
const order: string[] = []
|
|
|
|
class ClosingWriteStream extends FakeWriteStream {
|
|
close(cb: (err?: Error | null) => void) {
|
|
order.push('close')
|
|
// Like fs.WriteStream: end the stream, release the fd, then call back.
|
|
this.ended = true
|
|
setTimeout(() => cb(), 0)
|
|
}
|
|
}
|
|
|
|
const ws = new ClosingWriteStream()
|
|
const { deps, renamed } = recordingDeps(ws)
|
|
|
|
deps.rename = async (from, to) => {
|
|
order.push('rename')
|
|
renamed.push([from, to])
|
|
}
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
res.emit('end')
|
|
|
|
await promise
|
|
|
|
assert.deepEqual(order, ['close', 'rename'])
|
|
assert.equal(renamed.length, 1)
|
|
assert.equal(renamed[0][1], '/tmp/out.bin')
|
|
})
|
|
|
|
test('pumpStreamToFile applies backpressure: pauses on a full buffer and resumes on drain', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream([false]) // first write signals "buffer full"
|
|
const { deps } = recordingDeps(ws)
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('big-chunk'))
|
|
assert.equal(res.paused, true, 'source should be paused when write() returns false')
|
|
assert.equal(res.resumed, false)
|
|
|
|
ws.emit('drain')
|
|
assert.equal(res.resumed, true, 'source should resume after the write stream drains')
|
|
|
|
res.emit('end')
|
|
await promise
|
|
})
|
|
|
|
test('pumpStreamToFile removes only the temp file and rejects on a write error', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream()
|
|
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
ws.emit('error', new Error('ENOSPC: disk full'))
|
|
|
|
await assert.rejects(promise, /disk full/)
|
|
assert.deepEqual(unlinked, [opened[0]])
|
|
assertTempPathBeside(unlinked[0], '/tmp/out.bin')
|
|
assert.deepEqual(renamed, [], 'a failed body must never be moved onto the destination')
|
|
assert.equal(res.destroyed, true, 'source should be torn down on write failure')
|
|
})
|
|
|
|
test('pumpStreamToFile waits for the write stream to close before unlinking the temp file', async () => {
|
|
const res = new FakeResponse()
|
|
const order: string[] = []
|
|
|
|
class SlowCloseWriteStream extends FakeWriteStream {
|
|
destroy() {
|
|
this.destroyed = true
|
|
order.push('destroy')
|
|
// Release the fd later than a microtask: cleanup must still wait for it.
|
|
setTimeout(() => {
|
|
order.push('close')
|
|
this.emit('close')
|
|
}, 5)
|
|
}
|
|
}
|
|
|
|
const ws = new SlowCloseWriteStream()
|
|
const { deps, opened, unlinked } = recordingDeps(ws)
|
|
|
|
deps.unlink = async (p: string) => {
|
|
order.push('unlink')
|
|
unlinked.push(p)
|
|
}
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
res.emit('error', new Error('socket hang up'))
|
|
|
|
await assert.rejects(promise, /socket hang up/)
|
|
assert.deepEqual(order, ['destroy', 'close', 'unlink'])
|
|
assert.deepEqual(unlinked, [opened[0]])
|
|
})
|
|
|
|
// Ownership gate: an exclusive create can fail BEFORE this pump owns anything at
|
|
// the temp path (EEXIST on a collision). Cleanup must not unlink a file it did
|
|
// not create, or the destructive class moves from the destination to the temp
|
|
// name.
|
|
test('pumpStreamToFile never unlinks a temp path it did not create when the exclusive open fails', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream([], { opens: false })
|
|
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
const eexist: any = new Error("EEXIST: file already exists, open '/tmp/.hermes-download-deadbeef.part'")
|
|
|
|
eexist.code = 'EEXIST'
|
|
ws.emit('error', eexist)
|
|
|
|
await assert.rejects(promise, /EEXIST/)
|
|
assert.equal(opened.length, 1, 'one create attempt')
|
|
assert.deepEqual(unlinked, [], 'the colliding file belongs to someone else and must survive')
|
|
assert.deepEqual(renamed, [])
|
|
assert.equal(res.destroyed, true)
|
|
})
|
|
|
|
test('pumpStreamToFile honours tempPathFor so a regression can pin the temp path', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream()
|
|
const { deps, opened, renamed } = recordingDeps(ws)
|
|
|
|
deps.tempPathFor = () => '/tmp/pinned.part'
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
res.emit('end')
|
|
|
|
await promise
|
|
|
|
assert.deepEqual(opened, ['/tmp/pinned.part'])
|
|
assert.deepEqual(renamed, [['/tmp/pinned.part', '/tmp/out.bin']])
|
|
})
|
|
|
|
test('writeBufferToFile streams the buffer through the same temp-then-rename contract', async () => {
|
|
const ws = new FakeWriteStream()
|
|
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
|
|
|
await writeBufferToFile(Buffer.from('whole body'), '/tmp/out.bin', deps)
|
|
|
|
assert.equal(Buffer.concat(ws.chunks).toString('utf8'), 'whole body')
|
|
assert.equal(opened.length, 1)
|
|
assertTempPathBeside(opened[0], '/tmp/out.bin')
|
|
assert.deepEqual(renamed, [[opened[0], '/tmp/out.bin']])
|
|
assert.deepEqual(unlinked, [])
|
|
})
|
|
|
|
test('writeBufferToFile leaves the destination untouched when the write fails after open', async () => {
|
|
// fs.WriteStream surfaces a write failure before 'finish', never after, so
|
|
// the fake errors from write() itself.
|
|
class FailingWriteStream extends FakeWriteStream {
|
|
write(chunk: Buffer): boolean {
|
|
super.write(chunk)
|
|
this.emit('error', new Error('ENOSPC: disk full'))
|
|
|
|
return true
|
|
}
|
|
}
|
|
|
|
const ws = new FailingWriteStream()
|
|
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
|
|
|
await assert.rejects(writeBufferToFile(Buffer.from('whole body'), '/tmp/out.bin', deps), /disk full/)
|
|
assert.ok(!opened.includes('/tmp/out.bin'))
|
|
assert.deepEqual(unlinked, [opened[0]], 'only the owned temp file is removed')
|
|
assert.deepEqual(renamed, [])
|
|
})
|
|
|
|
// Regression for #96597: opening the destination directly truncated it as soon
|
|
// as the stream opened, and the error path then unlinked it — so a gateway
|
|
// hiccup mid-download destroyed a pre-existing file the user had chosen to
|
|
// overwrite. The destination must be neither opened nor removed on failure.
|
|
test('pumpStreamToFile leaves a pre-existing destination untouched when the response fails mid-stream', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream()
|
|
const { deps, opened, renamed, unlinked } = recordingDeps(ws)
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
res.emit('error', new Error('socket hang up'))
|
|
|
|
await assert.rejects(promise, /socket hang up/)
|
|
assert.ok(!opened.includes('/tmp/out.bin'), 'destination must not be opened (and truncated) before the body lands')
|
|
assert.ok(!unlinked.includes('/tmp/out.bin'), 'destination must not be removed on failure')
|
|
assert.deepEqual(unlinked, [opened[0]])
|
|
assert.deepEqual(renamed, [])
|
|
})
|
|
|
|
test('pumpStreamToFile removes the temp file and rejects when the final rename fails', async () => {
|
|
const res = new FakeResponse()
|
|
const ws = new FakeWriteStream()
|
|
const { deps, opened, unlinked } = recordingDeps(ws, { renameError: new Error('EPERM: destination locked') })
|
|
|
|
const promise = pumpStreamToFile(res as never, '/tmp/out.bin', deps)
|
|
|
|
res.emit('data', Buffer.from('abc'))
|
|
res.emit('end')
|
|
|
|
await assert.rejects(promise, /destination locked/)
|
|
assert.deepEqual(unlinked, [opened[0]], 'the temp file must not be left behind after a failed rename')
|
|
assert.ok(!unlinked.includes('/tmp/out.bin'))
|
|
})
|
|
|
|
test('parseDataUrlToBuffer decodes base64 payloads', () => {
|
|
const buffer = parseDataUrlToBuffer('data:text/markdown;base64,IyByZXBvcnQ=')
|
|
|
|
assert.equal(buffer.toString('utf8'), '# report')
|
|
})
|
|
|
|
test('parseDataUrlToBuffer decodes percent-encoded (non-base64) payloads', () => {
|
|
const buffer = parseDataUrlToBuffer('data:text/plain,hello%20world')
|
|
|
|
assert.equal(buffer.toString('utf8'), 'hello world')
|
|
})
|
|
|
|
test('parseDataUrlToBuffer throws on a malformed data URL', () => {
|
|
assert.throws(() => parseDataUrlToBuffer('not-a-data-url'), /Malformed data URL/)
|
|
})
|
|
|
|
test('filenameFromContentDisposition prefers filename* and reduces to a basename', () => {
|
|
assert.equal(
|
|
filenameFromContentDisposition("attachment; filename*=UTF-8''report%20with%20spaces.pdf"),
|
|
'report with spaces.pdf'
|
|
)
|
|
assert.equal(filenameFromContentDisposition('attachment; filename="report.md"'), 'report.md')
|
|
// A traversal attempt in the header cannot escape the chosen directory.
|
|
assert.equal(filenameFromContentDisposition('attachment; filename="../../etc/passwd"'), 'passwd')
|
|
assert.equal(filenameFromContentDisposition(''), '')
|
|
assert.equal(filenameFromContentDisposition(undefined), '')
|
|
})
|
|
|
|
test('gatewayFilePath normalizes bare paths and file:// URLs', () => {
|
|
assert.equal(gatewayFilePath('/Users/me/report.md'), '/Users/me/report.md')
|
|
assert.equal(gatewayFilePath('file:///Users/me/a%20b.md'), '/Users/me/a b.md')
|
|
assert.equal(gatewayFilePath(''), '')
|
|
assert.equal(gatewayFilePath(null), '')
|
|
})
|
|
|
|
test('gatewayFileRequestPaths keeps streaming and fallback requests on the same registered backend', () => {
|
|
const paths = gatewayFileRequestPaths('/srv/output/image one.png', requestPath =>
|
|
pathForRegistryBackendRequest(requestPath, 'research', { sharedRemote: true })
|
|
)
|
|
|
|
assert.deepEqual(paths, {
|
|
dataUrl: '/api/fs/read-data-url?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research',
|
|
download: '/api/fs/download?path=%2Fsrv%2Foutput%2Fimage+one.png&profile=research'
|
|
})
|
|
})
|
|
|
|
test('isNotFoundError matches only HTTP 404', () => {
|
|
const notFound: any = new Error('404: missing')
|
|
|
|
notFound.statusCode = 404
|
|
assert.equal(isNotFoundError(notFound), true)
|
|
|
|
const forbidden: any = new Error('403: nope')
|
|
|
|
forbidden.statusCode = 403
|
|
assert.equal(isNotFoundError(forbidden), false)
|
|
assert.equal(isNotFoundError(new Error('plain')), false)
|
|
assert.equal(isNotFoundError(null), false)
|
|
})
|
|
|
|
test('resolveGatewayFileBackend pins registered files to their owning connection', async () => {
|
|
const calls: string[] = []
|
|
|
|
const route = await resolveGatewayFileBackend(
|
|
{ connectionId: ' work-ssh ', profile: ' default ' },
|
|
{
|
|
ensureLegacy: async profile => {
|
|
calls.push(`legacy:${profile}`)
|
|
|
|
return { baseUrl: 'http://local.invalid' }
|
|
},
|
|
ensureRegistry: async (connectionId, profile) => {
|
|
calls.push(`registry:${connectionId}:${profile}`)
|
|
|
|
return { baseUrl: 'http://ssh.invalid' }
|
|
}
|
|
}
|
|
)
|
|
|
|
assert.deepEqual(calls, ['registry:work-ssh:default'])
|
|
assert.deepEqual(route, {
|
|
connection: { baseUrl: 'http://ssh.invalid' },
|
|
connectionId: 'work-ssh',
|
|
profile: 'default'
|
|
})
|
|
})
|
|
|
|
test('resolveGatewayFileBackend preserves the legacy route when no connection owns the file', async () => {
|
|
const calls: string[] = []
|
|
|
|
const route = await resolveGatewayFileBackend(
|
|
{ profile: 'coder' },
|
|
{
|
|
ensureLegacy: async profile => {
|
|
calls.push(`legacy:${profile}`)
|
|
|
|
return { baseUrl: 'http://local.invalid' }
|
|
},
|
|
ensureRegistry: async connectionId => {
|
|
calls.push(`registry:${connectionId}`)
|
|
|
|
return { baseUrl: 'http://remote.invalid' }
|
|
}
|
|
}
|
|
)
|
|
|
|
assert.deepEqual(calls, ['legacy:coder'])
|
|
assert.equal(route.connectionId, null)
|
|
assert.equal(route.profile, 'coder')
|
|
assert.deepEqual(route.connection, { baseUrl: 'http://local.invalid' })
|
|
})
|