mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
fix(artifacts): raise desktop sharing limit to 5 MiB (#17708)
* fix(artifacts): raise desktop sharing limit to 5 MiB * fix(artifacts): enforce recovery content limit * fix(artifacts): bound recovery request envelopes * fix(artifacts): clarify oversized request error
This commit is contained in:
@@ -3,7 +3,11 @@ import { mkdtemp, readFile, readdir, rm, stat, truncate, writeFile } from 'node:
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES, artifactWriteRequestByteLength } from '../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_MAX_CONTENT_BYTES,
|
||||
ARTIFACT_MAX_REQUEST_BYTES,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../shared/artifacts'
|
||||
import {
|
||||
MAX_ARTIFACT_CREATE_INTENT_BYTES,
|
||||
MAX_PENDING_ARTIFACT_CREATES,
|
||||
@@ -270,12 +274,12 @@ describe('artifact create intent store', () => {
|
||||
).toThrow(/unsupported format/)
|
||||
})
|
||||
|
||||
it('persists a valid artifact request near the RPC limit', async () => {
|
||||
it('persists a 5 MiB escaped artifact within the recovery limit', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const nearLimitBody = { ...body, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES - 200) }
|
||||
const nearLimitBody = { ...body, content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES) }
|
||||
expect(
|
||||
artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...nearLimitBody })
|
||||
).toBeLessThanOrEqual(ARTIFACT_CLI_MAX_RPC_BYTES)
|
||||
).toBeLessThanOrEqual(ARTIFACT_MAX_REQUEST_BYTES)
|
||||
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
@@ -289,7 +293,41 @@ describe('artifact create intent store', () => {
|
||||
).not.toThrow()
|
||||
const directory = join(userDataPath, 'profiles', 'local-profile', 'artifact-create-intents')
|
||||
const [fileName] = await readdir(directory)
|
||||
expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_CLI_MAX_RPC_BYTES)
|
||||
expect((await stat(join(directory, fileName))).size).toBeGreaterThan(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
})
|
||||
|
||||
it('rejects oversized artifact content before creating a recovery record', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
{ ...body, content: 'x'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) }
|
||||
)
|
||||
).toThrow(/5 MiB limit/)
|
||||
})
|
||||
|
||||
it('rejects a recovery body whose escaped request exceeds the transport budget', async () => {
|
||||
const userDataPath = await createUserDataPath()
|
||||
const content = '\u0000'.repeat(Math.ceil(ARTIFACT_MAX_REQUEST_BYTES / 6))
|
||||
expect(content.length).toBeLessThan(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
expect(
|
||||
artifactWriteRequestByteLength({ sourceKey: '/repo/report.html', ...body, content })
|
||||
).toBeGreaterThan(ARTIFACT_MAX_REQUEST_BYTES)
|
||||
|
||||
expect(() =>
|
||||
getOrCreateArtifactCreateIntent(
|
||||
'local-profile',
|
||||
userDataPath,
|
||||
'/repo/report.html',
|
||||
scope,
|
||||
'key-a',
|
||||
{ ...body, content }
|
||||
)
|
||||
).toThrow(/supported size/)
|
||||
})
|
||||
|
||||
it('rejects an oversized recovery record before reading it', async () => {
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_MAX_CONTENT_BYTES,
|
||||
ARTIFACT_MAX_REQUEST_BYTES,
|
||||
artifactContentByteLength,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../shared/artifacts'
|
||||
import {
|
||||
bestEffortFsyncDirectorySync,
|
||||
fsyncFileSync,
|
||||
@@ -21,7 +26,7 @@ import type { ArtifactWriteBody } from './artifact-cloud-request'
|
||||
import type { ArtifactShareScope } from './artifact-share-record-store'
|
||||
|
||||
export const MAX_PENDING_ARTIFACT_CREATES = 32
|
||||
export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_CLI_MAX_RPC_BYTES + 128 * 1024
|
||||
export const MAX_ARTIFACT_CREATE_INTENT_BYTES = ARTIFACT_MAX_REQUEST_BYTES + 128 * 1024
|
||||
|
||||
const MAX_HARDENED_INTENT_DIRECTORIES = 64
|
||||
const hardenedIntentDirectories = new Set<string>()
|
||||
@@ -116,12 +121,17 @@ function isWriteBody(value: unknown): value is ArtifactWriteBody {
|
||||
const body = value as Partial<ArtifactWriteBody>
|
||||
return (
|
||||
typeof body.content === 'string' &&
|
||||
artifactContentByteLength(body.content) <= ARTIFACT_MAX_CONTENT_BYTES &&
|
||||
(body.contentType === 'text/html' || body.contentType === 'text/markdown') &&
|
||||
typeof body.fileName === 'string' &&
|
||||
(body.title === undefined || typeof body.title === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function artifactIntentRequestByteLength(sourceKey: string, body: ArtifactWriteBody): number {
|
||||
return artifactWriteRequestByteLength({ sourceKey, ...body })
|
||||
}
|
||||
|
||||
function isScope(value: unknown): value is ArtifactShareScope {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
@@ -165,6 +175,9 @@ function readIntent(path: string): ArtifactCreateIntent {
|
||||
) {
|
||||
throw new Error('Artifact create recovery record has an unsupported format.')
|
||||
}
|
||||
if (artifactIntentRequestByteLength(intent.sourceKey, intent.body) > ARTIFACT_MAX_REQUEST_BYTES) {
|
||||
throw new Error('Artifact create recovery record exceeds the supported size.')
|
||||
}
|
||||
return intent as ArtifactCreateIntent
|
||||
}
|
||||
|
||||
@@ -193,6 +206,12 @@ export function getOrCreateArtifactCreateIntent(
|
||||
idempotencyKey: string,
|
||||
body: ArtifactWriteBody
|
||||
): ArtifactCreateIntent {
|
||||
if (artifactContentByteLength(body.content) > ARTIFACT_MAX_CONTENT_BYTES) {
|
||||
throw new Error('Artifact content exceeds the 5 MiB limit.')
|
||||
}
|
||||
if (artifactIntentRequestByteLength(sourceKey, body) > ARTIFACT_MAX_REQUEST_BYTES) {
|
||||
throw new Error('Artifact create recovery record exceeds the supported size.')
|
||||
}
|
||||
const existing = getArtifactCreateIntent(profileId, userDataPath, sourceKey, scope)
|
||||
if (existing) {
|
||||
return existing
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_MAX_CONTENT_BYTES,
|
||||
ARTIFACT_MAX_REQUEST_BYTES
|
||||
} from '../../../../shared/artifacts'
|
||||
import { ARTIFACT_METHODS } from './artifacts'
|
||||
|
||||
const validRequest = {
|
||||
@@ -32,14 +35,55 @@ describe('artifact RPC schemas', () => {
|
||||
const schema = writeSchema('artifacts.publish')
|
||||
expect(schema.safeParse({ ...validRequest, content: '' }).success).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_CLI_MAX_RPC_BYTES + 1) })
|
||||
schema.safeParse({ ...validRequest, content: 'x'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1) })
|
||||
.success
|
||||
).toBe(false)
|
||||
expect(
|
||||
schema.safeParse({
|
||||
...validRequest,
|
||||
content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
|
||||
content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1)
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts a 5 MiB UTF-8 artifact at the content boundary', () => {
|
||||
expect(
|
||||
writeSchema('artifacts.publish').safeParse({
|
||||
...validRequest,
|
||||
content: 'a'.repeat(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('measures the content boundary in UTF-8 bytes', () => {
|
||||
const exact = `${'€'.repeat(Math.floor(ARTIFACT_MAX_CONTENT_BYTES / 3))}aa`
|
||||
const oversized = `${exact}€`
|
||||
expect(new TextEncoder().encode(exact).byteLength).toBe(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
expect(new TextEncoder().encode(oversized).byteLength).toBeGreaterThan(
|
||||
ARTIFACT_MAX_CONTENT_BYTES
|
||||
)
|
||||
expect(
|
||||
writeSchema('artifacts.publish').safeParse({ ...validRequest, content: exact }).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
writeSchema('artifacts.publish').safeParse({ ...validRequest, content: oversized }).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows JSON escaping within the bounded 5 MiB content request', () => {
|
||||
expect(
|
||||
writeSchema('artifacts.publish').safeParse({
|
||||
...validRequest,
|
||||
content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
}).success
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an escaped envelope beyond the request budget', () => {
|
||||
const content = '\u0000'.repeat(Math.ceil(ARTIFACT_MAX_REQUEST_BYTES / 6))
|
||||
expect(new TextEncoder().encode(content).byteLength).toBeLessThan(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
expect(writeSchema('artifacts.publish').safeParse({ ...validRequest, content }).success).toBe(
|
||||
false
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
ARTIFACT_CLI_MAX_RPC_BYTES,
|
||||
ARTIFACT_MAX_CONTENT_BYTES,
|
||||
ARTIFACT_MAX_REQUEST_BYTES,
|
||||
artifactContentByteLength,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../../../shared/artifacts'
|
||||
import { defineMethod, type RpcAnyMethod } from '../core'
|
||||
@@ -23,14 +25,20 @@ const SourceRequest = z.object({
|
||||
const WriteRequest = z
|
||||
.object({
|
||||
sourceKey: z.string().min(1).max(32_768),
|
||||
content: z.string().min(1).max(ARTIFACT_CLI_MAX_RPC_BYTES),
|
||||
content: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
.refine((content) => artifactContentByteLength(content) <= ARTIFACT_MAX_CONTENT_BYTES, {
|
||||
message: 'Artifact content exceeds the 5 MiB limit.'
|
||||
}),
|
||||
contentType: z.enum(['text/html', 'text/markdown']),
|
||||
fileName: z.string().min(1).max(512),
|
||||
title: z.string().max(512).optional(),
|
||||
...CloudOptions
|
||||
})
|
||||
.refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_CLI_MAX_RPC_BYTES, {
|
||||
message: 'Artifact request exceeds the local RPC size limit.'
|
||||
.refine((request) => artifactWriteRequestByteLength(request) <= ARTIFACT_MAX_REQUEST_BYTES, {
|
||||
message: 'Artifact request exceeds the supported size.'
|
||||
})
|
||||
|
||||
export const ARTIFACT_METHODS: readonly RpcAnyMethod[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../shared/artifacts'
|
||||
import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../shared/artifacts'
|
||||
import { publishArtifactFromSurface } from './artifact-publish-flow'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -91,16 +91,31 @@ describe('artifact publish flow', () => {
|
||||
it('rejects an oversized request before RPC', async () => {
|
||||
const createRequest = vi.fn().mockResolvedValue({
|
||||
...request,
|
||||
content: '"'.repeat(Math.floor(ARTIFACT_CLI_MAX_RPC_BYTES / 2))
|
||||
content: '"'.repeat(ARTIFACT_MAX_CONTENT_BYTES + 1)
|
||||
})
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBeNull()
|
||||
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('Could not share artifact', {
|
||||
description: 'Artifacts shared from Orca must be smaller than 800 KB.'
|
||||
description: 'This artifact is too large to share.'
|
||||
})
|
||||
})
|
||||
|
||||
it('publishes content at the 5 MiB boundary', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
|
||||
const createRequest = vi.fn().mockResolvedValue({
|
||||
...request,
|
||||
content: 'a'.repeat(ARTIFACT_MAX_CONTENT_BYTES)
|
||||
})
|
||||
|
||||
await expect(publishArtifactFromSurface(createRequest)).resolves.toBe(published)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'artifacts.publish',
|
||||
expect.objectContaining({ content: 'a'.repeat(ARTIFACT_MAX_CONTENT_BYTES) })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows confirmation without putting the public link in the toast', async () => {
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ status: 'ok', value: published })
|
||||
await publishArtifactFromSurface(() => Promise.resolve(request))
|
||||
|
||||
@@ -5,7 +5,9 @@ import type {
|
||||
ArtifactWriteRequest
|
||||
} from '../../../../shared/artifacts'
|
||||
import {
|
||||
ARTIFACT_CLI_MAX_RPC_BYTES,
|
||||
ARTIFACT_MAX_CONTENT_BYTES,
|
||||
ARTIFACT_MAX_REQUEST_BYTES,
|
||||
artifactContentByteLength,
|
||||
artifactWriteRequestByteLength
|
||||
} from '../../../../shared/artifacts'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -33,7 +35,10 @@ export function validateArtifactPublishRequest(
|
||||
if (!request.content) {
|
||||
throw new ArtifactPublishPreparationError('empty')
|
||||
}
|
||||
if (artifactWriteRequestByteLength(request) > ARTIFACT_CLI_MAX_RPC_BYTES) {
|
||||
if (
|
||||
artifactContentByteLength(request.content) > ARTIFACT_MAX_CONTENT_BYTES ||
|
||||
artifactWriteRequestByteLength(request) > ARTIFACT_MAX_REQUEST_BYTES
|
||||
) {
|
||||
throw new ArtifactPublishPreparationError('too-large')
|
||||
}
|
||||
return request
|
||||
@@ -123,7 +128,7 @@ function artifactPreparationErrorDescription(code: ArtifactPublishPreparationErr
|
||||
case 'too-large':
|
||||
return translate(
|
||||
'auto.components.artifacts.artifact-publish-flow.6112db5a1c',
|
||||
'Artifacts shared from Orca must be smaller than 800 KB.'
|
||||
'This artifact is too large to share.'
|
||||
)
|
||||
case 'unreadable':
|
||||
return translate(
|
||||
|
||||
+14
-2
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../../shared/artifacts'
|
||||
import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../../shared/artifacts'
|
||||
import type { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
|
||||
import {
|
||||
getShareableBrowserArtifactFile,
|
||||
@@ -49,7 +49,7 @@ describe('browser artifact upload', () => {
|
||||
|
||||
it('rejects oversized and unreadable files before upload', async () => {
|
||||
stat.mockResolvedValueOnce({
|
||||
size: ARTIFACT_CLI_MAX_RPC_BYTES + 1,
|
||||
size: ARTIFACT_MAX_CONTENT_BYTES + 1,
|
||||
isDirectory: false,
|
||||
mtime: 1
|
||||
})
|
||||
@@ -63,4 +63,16 @@ describe('browser artifact upload', () => {
|
||||
code: 'unreadable'
|
||||
} satisfies Partial<ArtifactPublishPreparationError>)
|
||||
})
|
||||
|
||||
it('accepts a file whose stat is exactly at the 5 MiB boundary', async () => {
|
||||
stat.mockResolvedValueOnce({
|
||||
size: ARTIFACT_MAX_CONTENT_BYTES,
|
||||
isDirectory: false,
|
||||
mtime: 1
|
||||
})
|
||||
|
||||
await expect(readBrowserHtmlArtifactRequest('file:///tmp/exact.html')).resolves.toMatchObject({
|
||||
sourceKey: '/tmp/exact.html'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ArtifactWriteRequest } from '../../../../../shared/artifacts'
|
||||
import { ARTIFACT_CLI_MAX_RPC_BYTES } from '../../../../../shared/artifacts'
|
||||
import { ARTIFACT_MAX_CONTENT_BYTES } from '../../../../../shared/artifacts'
|
||||
import { getRuntimePathBasename } from '../../../../../shared/cross-platform-path'
|
||||
import { ArtifactPublishPreparationError } from '@/components/artifacts/artifact-publish-flow'
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function readBrowserHtmlArtifactRequest(url: string): Promise<Artif
|
||||
if (stat.isDirectory) {
|
||||
throw new ArtifactPublishPreparationError('unsupported')
|
||||
}
|
||||
if (stat.size > ARTIFACT_CLI_MAX_RPC_BYTES) {
|
||||
if (stat.size > ARTIFACT_MAX_CONTENT_BYTES) {
|
||||
throw new ArtifactPublishPreparationError('too-large')
|
||||
}
|
||||
const result = await window.api.fs.readFile({ filePath: file.filePath })
|
||||
|
||||
@@ -16415,7 +16415,7 @@
|
||||
"2fc727c831": "Artifact updated",
|
||||
"5cb4f5ec36": "Copy link",
|
||||
"fbb5018602": "This file is empty.",
|
||||
"6112db5a1c": "Artifacts shared from Orca must be smaller than 800 KB.",
|
||||
"6112db5a1c": "This artifact is too large to share.",
|
||||
"e2ed5acd8c": "Orca couldn't read this file. Open it from a workspace and try again.",
|
||||
"6d475e9b25": "Only local HTML and Markdown files can be shared as artifacts.",
|
||||
"29a406be09": "Artifacts must contain text."
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
/** Maximum UTF-8 bytes accepted for a manually shared artifact. */
|
||||
export const ARTIFACT_MAX_CONTENT_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Legacy CLI/SSH envelope cap; those transports still have ~1 MiB control frames. */
|
||||
export const ARTIFACT_CLI_MAX_RPC_BYTES = 800 * 1024
|
||||
|
||||
/** Allows JSON escaping while staying below the cloud API's 11 MiB body budget. */
|
||||
export const ARTIFACT_MAX_REQUEST_BYTES = 11 * 1024 * 1024
|
||||
|
||||
export function artifactContentByteLength(content: string): number {
|
||||
return new TextEncoder().encode(content).byteLength
|
||||
}
|
||||
|
||||
export function artifactWriteRequestByteLength(request: ArtifactWriteRequest): number {
|
||||
return new TextEncoder().encode(JSON.stringify(request)).byteLength
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user