Skip to content
Closed
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
52 changes: 52 additions & 0 deletions src/cli/bounded-stdin-text.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { readStreamBytesWithinLimit, readTextStreamWithinLimit } from './bounded-stdin-text'

const tooLarge = (): Error => new Error('too large')

async function* stream(
chunks: (Buffer | Uint8Array | string)[]
): AsyncGenerator<Buffer | Uint8Array | string> {
for (const chunk of chunks) {
yield chunk
}
}

describe('bounded stream reading', () => {
it('preserves the bytes of a plain Uint8Array chunk', async () => {
const bytes = new Uint8Array(Buffer.from('segundo café', 'utf8'))

await expect(readTextStreamWithinLimit(stream([bytes]), 64, tooLarge)).resolves.toBe(
'segundo café'
)
})

it('counts a Uint8Array chunk by its byte length, not its stringified form', async () => {
// 3 bytes stringify to "226,130,172", so a length-based limit must not see 11.
const euro = new Uint8Array(Buffer.from('€', 'utf8'))

await expect(readStreamBytesWithinLimit(stream([euro]), 3, tooLarge)).resolves.toEqual(
Buffer.from('€', 'utf8')
)
await expect(readStreamBytesWithinLimit(stream([euro]), 2, tooLarge)).rejects.toThrow(
'too large'
)
})

it('reads a Uint8Array view without dragging in the rest of its backing buffer', async () => {
const backing = Buffer.from('prefix-payload-suffix', 'utf8')
const view = new Uint8Array(backing.buffer, backing.byteOffset + 7, 7)

await expect(readTextStreamWithinLimit(stream([view]), 64, tooLarge)).resolves.toBe('payload')
})

it('accepts string chunks and joins multi-byte characters split across chunks', async () => {
const cafe = Buffer.from('café', 'utf8')

await expect(readTextStreamWithinLimit(stream(['ab', 'cd']), 64, tooLarge)).resolves.toBe(
'abcd'
)
await expect(
readTextStreamWithinLimit(stream([cafe.subarray(0, -1), cafe.subarray(-1)]), 64, tooLarge)
).resolves.toBe('café')
})
})
46 changes: 46 additions & 0 deletions src/cli/bounded-stdin-text.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
type BoundedStreamChunk = Buffer | Uint8Array | string

export async function readStreamBytesWithinLimit(
input: AsyncIterable<BoundedStreamChunk>,
maxBytes: number,
createTooLargeError: () => Error
): Promise<Buffer> {
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of input) {
// Why: a plain Uint8Array has to be wrapped, not stringified, or its bytes become decimal text.
const buffer = Buffer.isBuffer(chunk)
? chunk
: typeof chunk === 'string'
? Buffer.from(chunk, 'utf8')
: Buffer.from(chunk)
bytes += buffer.length
if (bytes > maxBytes) {
throw createTooLargeError()
}
chunks.push(buffer)
}
return Buffer.concat(chunks)
}

export async function readTextStreamWithinLimit(
input: AsyncIterable<BoundedStreamChunk>,
maxBytes: number,
createTooLargeError: () => Error
): Promise<string> {
return (await readStreamBytesWithinLimit(input, maxBytes, createTooLargeError)).toString('utf8')
}

export function readStdinBytesWithinLimit(
maxBytes: number,
createTooLargeError: () => Error
): Promise<Buffer> {
return readStreamBytesWithinLimit(process.stdin, maxBytes, createTooLargeError)
}

export function readStdinTextWithinLimit(
maxBytes: number,
createTooLargeError: () => Error
): Promise<string> {
return readTextStreamWithinLimit(process.stdin, maxBytes, createTooLargeError)
}
27 changes: 9 additions & 18 deletions src/cli/handlers/artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import type { CommandHandler, HandlerContext } from '../dispatch'
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
import { RuntimeClientError } from '../runtime-client'
import { formatArtifactListPage, formatArtifactShared } from '../artifact-format'
import { readStdinTextWithinLimit } from '../bounded-stdin-text'
import { printResult } from '../format'

function stringFlag(ctx: HandlerContext, name: string): string | undefined {
Expand Down Expand Up @@ -61,23 +62,6 @@ function artifactContentType(path: string): ArtifactWriteRequest['contentType']
: null
}

async function readStdinWithinLimit(maxBytes: number): Promise<string> {
const chunks: Buffer[] = []
let bytes = 0
for await (const chunk of process.stdin) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))
bytes += buffer.length
if (bytes > maxBytes) {
throw new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
}
chunks.push(buffer)
}
return Buffer.concat(chunks).toString('utf8')
}

/**
* Publishing is off by default, so denial is the common outcome. A tiny `settings.get` read
* answers it before we load (or pipe in) up to the full RPC byte budget the host would reject.
Expand Down Expand Up @@ -127,7 +111,14 @@ async function readArtifactRequest(ctx: HandlerContext): Promise<ArtifactWriteRe
)
}
const content = remoteInput
? await readStdinWithinLimit(ARTIFACT_CLI_MAX_RPC_BYTES)
? await readStdinTextWithinLimit(
ARTIFACT_CLI_MAX_RPC_BYTES,
() =>
new RuntimeClientError(
'invalid_argument',
'Artifact is too large for the Orca CLI transport. Use the browser upload page instead.'
)
)
: localRead?.status === 'ok'
? localRead.content
: ''
Expand Down
Loading