Explorar el Código

fix(web): harden paged search protocol (round 7)

Hypatia May hace 2 meses
padre
commit
40b68cd8d5

+ 19 - 10
packages/host/apiproxy/src/api-proxy.ts

@@ -667,16 +667,23 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
               ...cursor === undefined ? {} : { cursor },
             }, { signal })
             if (isAborted(signal)) return cancelled()
-            if (page.items.length > SESSION_SEARCH_LIMIT) {
+            const providerItemCount = page.items.length
+            if (providerItemCount > SESSION_SEARCH_LIMIT) {
               throw new Error(
-                `session search provider returned ${page.items.length} items; maximum is ${SESSION_SEARCH_LIMIT}`,
+                `session search provider returned ${providerItemCount} items; maximum is ${SESSION_SEARCH_LIMIT}`,
               )
             }
             // Host visibility is the authorization boundary. Consume the
             // provider's globally ranked stream rather than binding every
             // visible id into one SQLite statement, then re-check complete
-            // provenance before emitting any snippet.
-            for (const hit of page.items) {
+            // provenance before emitting any snippet. Inspect exactly the
+            // declared array entries so a custom iterator cannot overproduce.
+            for (let itemIndex = 0; itemIndex < providerItemCount; itemIndex++) {
+              const hit = page.items[itemIndex]
+              if (hit === undefined) {
+                throw new Error(`session search provider omitted item at index ${itemIndex}`)
+              }
+              if (authorized.length > SESSION_SEARCH_LIMIT) continue
               if (
                 !visibleIds.has(hit.header.id)
                 || hit.bestMatch.sessionId !== hit.header.id
@@ -689,14 +696,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
                 sessionId: hit.header.id,
                 snippet: hit.bestMatch.snippet,
               })
-              if (authorized.length > SESSION_SEARCH_LIMIT) break
             }
-            if (authorized.length > SESSION_SEARCH_LIMIT || page.nextCursor === undefined) break
-            if (seenCursors.has(page.nextCursor)) {
-              throw new Error('session search provider repeated a continuation cursor')
+            const nextCursor = page.nextCursor
+            if (nextCursor !== undefined) {
+              if (seenCursors.has(nextCursor)) {
+                throw new Error('session search provider repeated a continuation cursor')
+              }
+              seenCursors.add(nextCursor)
             }
-            seenCursors.add(page.nextCursor)
-            cursor = page.nextCursor
+            if (authorized.length > SESSION_SEARCH_LIMIT || nextCursor === undefined) break
+            cursor = nextCursor
           }
           return ok(request, {
             items: authorized.slice(0, SESSION_SEARCH_LIMIT),

+ 53 - 0
packages/host/apiproxy/tests/api-proxy-search.spec.ts

@@ -272,6 +272,33 @@ describe('session.search', () => {
     expect(iterate).not.toHaveBeenCalled()
   })
 
+  it('inspects only numerically stored items when a compliant page overrides iteration', async () => {
+    const ctx = await baseContext()
+    const visible = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
+    for (const item of visible) {
+      ctx.sessions.create(item.header.id, { meta: item.header })
+    }
+    const stored = visible.slice(0, 1)
+    const iterate = vi.fn(() => visible.values())
+    Object.defineProperty(stored, Symbol.iterator, { value: iterate })
+    const searchSessions = vi.fn(() => Promise.resolve({ items: stored }))
+    ctx.provide('sessionQuery', { searchSessions } as never)
+
+    const response = await createApiProxy(ctx, defaults).sessions.search(
+      request('custom-iterator'),
+      new AbortController().signal,
+    )
+
+    expect(response.result).toEqual({
+      ok: true,
+      value: {
+        items: [{ sessionId: 'visible-0', snippet: 'match 0' }],
+        hasMore: false,
+      },
+    })
+    expect(iterate).not.toHaveBeenCalled()
+  })
+
   it('fails closed when the provider repeats a continuation cursor', async () => {
     const ctx = await baseContext()
     ctx.sessions.create(sid('visible'), { meta: header('visible') })
@@ -292,6 +319,32 @@ describe('session.search', () => {
     expect(searchSessions).toHaveBeenCalledTimes(2)
   })
 
+  it('validates a repeated cursor before accepting the authorized lookahead', async () => {
+    const ctx = await baseContext()
+    const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))
+    for (const item of items) {
+      ctx.sessions.create(item.header.id, { meta: item.header })
+    }
+    const searchSessions = vi.fn()
+      .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' })
+      .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
+    ctx.provide('sessionQuery', { searchSessions } as never)
+
+    const response = await createApiProxy(ctx, defaults).sessions.search(
+      request('repeated-lookahead-cursor'),
+      new AbortController().signal,
+    )
+
+    expect(response.result).toMatchObject({
+      ok: false,
+      error: { code: 'internal' },
+    })
+    expect(response.result).not.toHaveProperty('value')
+    if (response.result.ok) throw new Error('unreachable')
+    expect(response.result.error.message).toContain('repeated a continuation cursor')
+    expect(searchSessions).toHaveBeenCalledTimes(2)
+  })
+
   it('does not count duplicate session ids toward the result or lookahead boundary', async () => {
     const ctx = await baseContext()
     const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index))