Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: walking dag-cbor paths #39

Merged
merged 7 commits into from
Apr 8, 2024
Merged
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
13 changes: 13 additions & 0 deletions packages/verified-fetch/src/utils/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,19 @@ export function notAcceptableResponse (url: string, body?: SupportedBodyTypes, i
return response
}

export function notFoundResponse (url: string, body?: SupportedBodyTypes, init?: ResponseInit): Response {
const response = new Response(body, {
...(init ?? {}),
status: 404,
statusText: 'Not Found'
})

setType(response, 'basic')
setUrl(response, url)

return response
}

/**
* if body is an Error, it will be converted to a string containing the error message.
*/
Expand Down
9 changes: 7 additions & 2 deletions packages/verified-fetch/src/utils/walk-path.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type UnixFSEntry } from 'ipfs-unixfs-exporter'
import { CodeError } from '@libp2p/interface'
import { walkPath as exporterWalk, type ExporterOptions, type ReadableStorage, type ObjectNode, type UnixFSEntry } from 'ipfs-unixfs-exporter'
import type { CID } from 'multiformats/cid'

export interface PathWalkerOptions extends ExporterOptions {
Expand All @@ -24,11 +25,15 @@ export async function walkPath (blockstore: ReadableStorage, path: string, optio
}

if (terminalElement == null) {
throw new Error('No terminal element found')
throw new CodeError('No terminal element found', 'ERR_NO_TERMINAL_ELEMENT')
}

return {
ipfsRoots,
terminalElement
}
}

export function isObjectNode (node: UnixFSEntry): node is ObjectNode {
return node.type === 'object'
}
55 changes: 38 additions & 17 deletions packages/verified-fetch/src/verified-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { unixfs as heliaUnixFs, type UnixFS as HeliaUnixFs } from '@helia/unixfs
import * as ipldDagCbor from '@ipld/dag-cbor'
import * as ipldDagJson from '@ipld/dag-json'
import { code as dagPbCode } from '@ipld/dag-pb'
import { AbortError, type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
import { type AbortOptions, type Logger, type PeerId } from '@libp2p/interface'
import { Record as DHTRecord } from '@libp2p/kad-dht'
import { peerIdFromString } from '@libp2p/peer-id'
import { Key } from 'interface-datastore'
Expand All @@ -25,15 +25,15 @@ import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterab
import { tarStream } from './utils/get-tar-stream.js'
import { parseResource } from './utils/parse-resource.js'
import { setCacheControlHeader } from './utils/response-headers.js'
import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse } from './utils/responses.js'
import { badRequestResponse, movedPermanentlyResponse, notAcceptableResponse, notSupportedResponse, okResponse, badRangeResponse, okRangeResponse, badGatewayResponse, notFoundResponse } from './utils/responses.js'
import { selectOutputType } from './utils/select-output-type.js'
import { walkPath } from './utils/walk-path.js'
import { isObjectNode, walkPath } from './utils/walk-path.js'
import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
import type { RequestFormatShorthand } from './types.js'
import type { ParsedUrlStringResults } from './utils/parse-url-string'
import type { Helia } from '@helia/interface'
import type { DNSResolver } from '@multiformats/dns/resolvers'
import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
import type { ObjectNode, UnixFSEntry } from 'ipfs-unixfs-exporter'
import type { CID } from 'multiformats/cid'

interface VerifiedFetchComponents {
Expand Down Expand Up @@ -236,8 +236,31 @@ export class VerifiedFetch {

private async handleDagCbor ({ resource, cid, path, accept, options }: FetchHandlerFunctionArg): Promise<Response> {
this.log.trace('fetching %c/%s', cid, path)
let terminalElement: ObjectNode | undefined
let ipfsRoots: CID[] | undefined

// need to walk path, if it exists, to get the terminal element
try {
const pathDetails = await walkPath(this.helia.blockstore, `${cid.toString()}/${path}`, options)
ipfsRoots = pathDetails.ipfsRoots
const potentialTerminalElement = pathDetails.terminalElement
if (potentialTerminalElement == null) {
return notFoundResponse(resource)
}
if (isObjectNode(potentialTerminalElement)) {
terminalElement = potentialTerminalElement
}
} catch (err: any) {
options?.signal?.throwIfAborted()
if (['ERR_NO_PROP', 'ERR_NO_TERMINAL_ELEMENT'].includes(err.code)) {
return notFoundResponse(resource)
}

this.log.error('error walking path %s', path, err)
return badGatewayResponse(resource, 'Error walking path')
}
const block = terminalElement?.node ?? await this.helia.blockstore.get(cid, options)

const block = await this.helia.blockstore.get(cid, options)
let body: string | Uint8Array

if (accept === 'application/octet-stream' || accept === 'application/vnd.ipld.dag-cbor' || accept === 'application/cbor') {
Expand Down Expand Up @@ -277,6 +300,10 @@ export class VerifiedFetch {

response.headers.set('content-type', accept)

if (ipfsRoots != null) {
response.headers.set('X-Ipfs-Roots', ipfsRoots.map(cid => cid.toV1().toString()).join(',')) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-roots-response-header
}

return response
}

Expand All @@ -291,8 +318,9 @@ export class VerifiedFetch {
ipfsRoots = pathDetails.ipfsRoots
terminalElement = pathDetails.terminalElement
} catch (err: any) {
if (options?.signal?.aborted === true) {
throw new AbortError('signal aborted by user')
options?.signal?.throwIfAborted()
if (['ERR_NO_PROP', 'ERR_NO_TERMINAL_ELEMENT'].includes(err.code)) {
return notFoundResponse(resource.toString())
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

404 on bad paths for unixfs now too

}
this.log.error('error walking path %s', path, err)

Expand Down Expand Up @@ -331,9 +359,7 @@ export class VerifiedFetch {
path = rootFilePath
resolvedCID = stat.cid
} catch (err: any) {
if (options?.signal?.aborted === true) {
throw new AbortError('signal aborted by user')
}
options?.signal?.throwIfAborted()
this.log('error loading path %c/%s', dirCid, rootFilePath, err)
return notSupportedResponse('Unable to find index.html for directory at given path. Support for directories with implicit root is not implemented')
} finally {
Expand Down Expand Up @@ -377,9 +403,7 @@ export class VerifiedFetch {

return response
} catch (err: any) {
if (options?.signal?.aborted === true) {
throw new AbortError('signal aborted by user')
}
options?.signal?.throwIfAborted()
this.log.error('error streaming %c/%s', cid, path, err)
if (byteRangeContext.isRangeRequest && err.code === 'ERR_INVALID_PARAMS') {
return badRangeResponse(resource)
Expand Down Expand Up @@ -455,7 +479,6 @@ export class VerifiedFetch {
* TODO: move operations called by fetch to a queue of operations where we can
* always exit early (and cleanly) if a given signal is aborted
*/
// eslint-disable-next-line complexity
async fetch (resource: Resource, opts?: VerifiedFetchOptions): Promise<Response> {
this.log('fetch %s', resource)

Expand All @@ -477,9 +500,7 @@ export class VerifiedFetch {
ttl = result.ttl
protocol = result.protocol
} catch (err: any) {
if (options?.signal?.aborted === true) {
throw new AbortError('signal aborted by user')
}
options?.signal?.throwIfAborted()
this.log.error('error parsing resource %s', resource, err)

return badRequestResponse(resource.toString(), err)
Expand Down
31 changes: 31 additions & 0 deletions packages/verified-fetch/test/verified-fetch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -807,4 +807,35 @@ describe('@helia/verifed-fetch', () => {
expect(new Uint8Array(data)).to.equalBytes(finalRootFileContent)
})
})

describe('404 paths', () => {
let helia: Helia
let verifiedFetch: VerifiedFetch
let contentTypeParser: Sinon.SinonStub

beforeEach(async () => {
contentTypeParser = Sinon.stub()
helia = await createHelia()
verifiedFetch = new VerifiedFetch({
helia
}, {
contentTypeParser
})
})

afterEach(async () => {
await stop(helia, verifiedFetch)
})

it('returns a 404 when walking dag-cbor for non-existent path', async () => {
const obj = {
hello: 'world'
}
const c = dagCbor(helia)
const cid = await c.add(obj)

const resp = await verifiedFetch.fetch(`http://example.com/ipfs/${cid}/foo/i-do-not-exist`)
expect(resp.status).to.equal(404)
})
})
})
Loading