diff --git a/packages/agent/src/imported-artifact.ts b/packages/agent/src/imported-artifact.ts index e5bfb5bb1e..b4c9879e97 100644 --- a/packages/agent/src/imported-artifact.ts +++ b/packages/agent/src/imported-artifact.ts @@ -665,10 +665,12 @@ export class ImportedArtifactMethods extends DKGAgentBase { if (page.denied || page.unavailable || page.hashMismatch || page.bytesB64 == null) { return { response: page }; } + const pageByteLength = Buffer.byteLength(page.bytesB64, 'base64'); if ( page.offset !== requestedRange.offset || page.hash !== params.hash || - (page.totalBytes != null && page.offset + Buffer.byteLength(page.bytesB64, 'base64') > page.totalBytes) || + pageByteLength > requestedRange.maxBytes || + (page.totalBytes != null && page.offset + pageByteLength > page.totalBytes) || (page.truncated && (page.nextOffset == null || page.nextOffset <= page.offset)) ) { return { response: { ...page, bytesB64: undefined, hashMismatch: true } }; @@ -695,6 +697,10 @@ export class ImportedArtifactMethods extends DKGAgentBase { if (first.offset !== 0 || first.hash !== params.hash) { return { response: { ...first, bytesB64: undefined, hashMismatch: true } }; } + const firstByteLength = Buffer.byteLength(first.bytesB64, 'base64'); + if (firstByteLength > IMPORTED_ARTIFACT_MAX_PAGE_BYTES || first.offset + firstByteLength > total) { + return { response: { ...first, bytesB64: undefined, hashMismatch: true } }; + } const chunks = [Buffer.from(first.bytesB64, 'base64')]; let nextOffset = first.nextOffset; while (first.truncated && nextOffset != null && nextOffset < total) { @@ -707,10 +713,13 @@ export class ImportedArtifactMethods extends DKGAgentBase { if (page.denied || page.unavailable || page.hashMismatch || page.bytesB64 == null) { return { response: page }; } + const pageByteLength = Buffer.byteLength(page.bytesB64, 'base64'); if ( page.offset !== requestedOffset || page.hash !== params.hash || + pageByteLength > IMPORTED_ARTIFACT_MAX_PAGE_BYTES || (page.totalBytes != null && page.totalBytes !== total) || + page.offset + pageByteLength > total || (page.truncated && (page.nextOffset == null || page.nextOffset <= requestedOffset || page.nextOffset > total)) ) { return { response: { ...page, bytesB64: undefined, hashMismatch: true } }; diff --git a/packages/agent/test/imported-artifact.test.ts b/packages/agent/test/imported-artifact.test.ts index f9afc4c770..8c3fb3cbb8 100644 --- a/packages/agent/test/imported-artifact.test.ts +++ b/packages/agent/test/imported-artifact.test.ts @@ -678,6 +678,43 @@ describe('generic imported artifact peer handler', () => { })); }); + it('rejects an oversized unverified remote page', async () => { + const bytes = Buffer.from('abcdef'); + const artifactHash = keccakHash(bytes); + const agent = { + readAssertionArtifact: vi.fn(async ({ offset = 0 }) => ({ + version: 1, + contextGraphId, + assertionUri, + kind: 'source', + hash: artifactHash, + offset, + totalBytes: bytes.length + offset, + truncated: false, + bytesB64: bytes.toString('base64'), + })), + }; + + const res = await ImportedArtifactMethods.prototype.fetchAndVerifyAssertionArtifact.call(agent, { + contextGraphId, + assertionUri, + kind: 'source', + hash: artifactHash, + offset: 1, + maxBytes: 3, + sourcePeerId: 'peer-local', + cache: false, + }); + + expect(res.response.hashMismatch).toBe(true); + expect(res.response.bytesB64).toBeUndefined(); + expect(res.verifiedBytes).toBeUndefined(); + expect(agent.readAssertionArtifact).toHaveBeenCalledWith(expect.objectContaining({ + offset: 1, + maxBytes: 3, + })); + }); + it('serves requested pages for artifacts larger than the cache promotion cap', async () => { const firstPage = Buffer.from('first page'); const requestedPage = Buffer.from('requested page'); diff --git a/packages/cli/src/daemon/routes/knowledge-assets-import.ts b/packages/cli/src/daemon/routes/knowledge-assets-import.ts index a873f8abc4..79f02830f0 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets-import.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets-import.ts @@ -145,6 +145,7 @@ async function fetchFirstAvailableAssertionArtifact( resolved: AssertionArtifactResolution, opts: { sourcePeerIds: string[]; + explicitSourcePeerId?: boolean; offset: number; maxBytes: number; cache: boolean; @@ -168,7 +169,12 @@ async function fetchFirstAvailableAssertionArtifact( if (remote.verifiedBytes) return { availability: 'verified', remote, sourcePeerId }; const page = remote.response; if (!page.denied && !page.unavailable && !page.hashMismatch && page.bytesB64 != null) { - return { availability: 'unverified_page', remote, sourcePeerId }; + const result: AssertionArtifactRemoteResult = { availability: 'unverified_page', remote, sourcePeerId }; + if (opts.cache && !opts.explicitSourcePeerId) { + fallback ??= result; + continue; + } + return result; } fallback ??= { availability: 'unavailable', remote, sourcePeerId }; } @@ -325,6 +331,7 @@ export async function handleKaImportArtifactRead(ctx: RequestContext): Promise { await expect(fileStore.get(artifactHash)).resolves.toEqual(bytes); }); + it('keeps scanning discovered peers after an unverified page when cache promotion can verify a later peer', async () => { + const staleBytes = Buffer.from('# Stale Candidate\n'); + const bytes = Buffer.from('# Verified Candidate\n'); + const artifactHash = sha256Hash(bytes); + const contextGraphId = 'cg-public-open-discovered-unverified-fallback'; + const assertionName = 'imported-md'; + const assertionUri = contextGraphAssertionUri(contextGraphId, 'did:dkg:agent:source', assertionName); + const discoverAssertionArtifactCandidates = vi.fn(async () => ['peer-unverified', 'peer-good']); + const fetchAndVerifyAssertionArtifact = vi.fn(async (params: { sourcePeerId: string }) => { + if (params.sourcePeerId === 'peer-unverified') { + return { + response: { + version: 1, + contextGraphId, + assertionUri, + kind: 'markdown', + hash: artifactHash, + offset: 0, + totalBytes: staleBytes.length, + truncated: false, + contentType: 'text/markdown', + bytesB64: staleBytes.toString('base64'), + }, + }; + } + return { + response: { + version: 1, + contextGraphId, + assertionUri, + kind: 'markdown', + hash: artifactHash, + offset: 0, + totalBytes: bytes.length, + truncated: false, + contentType: 'text/markdown', + bytesB64: bytes.toString('base64'), + }, + verifiedBytes: bytes, + }; + }); + const { agent } = makeAgent({ + contextGraphId, + assertionName, + assertionUri, + fileHash: artifactHash, + markdownHash: artifactHash, + markdownForm: `urn:dkg:file:${artifactHash}`, + onChainPolicy: { accessPolicy: 0, publishPolicy: 1 }, + discoverAssertionArtifactCandidates, + fetchAndVerifyAssertionArtifact, + }); + await startRoutes({ agent }); + + const read = await post('/api/knowledge-assets/import-artifact/read', { + contextGraphId, + assertionUri, + kind: 'markdown', + hash: artifactHash, + maxBytes: 1024, + }); + + expect(read.status).toBe(200); + expect(read.body).toMatchObject({ + status: 'fetched', + hash: artifactHash, + bytesB64: bytes.toString('base64'), + source: { peerId: 'peer-good' }, + }); + expect(fetchAndVerifyAssertionArtifact.mock.calls.map(([params]) => params.sourcePeerId)).toEqual([ + 'peer-unverified', + 'peer-good', + ]); + await expect(fileStore.get(artifactHash)).resolves.toEqual(bytes); + }); + it('derives non-owner public + open read metadata from replicated SWM linkage when _meta is absent (#872 devnet)', async () => { // Devnet peers receive the promoted SWM assertion triples but do not // receive the origin node's CG-root `_meta` rows. The read route should