-
Notifications
You must be signed in to change notification settings - Fork 116
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
feat: download tars from @helia/verified-fetch #442
Changes from all commits
66c7617
67ddf99
97724a7
19b04a6
0ad29ea
9a1849c
8356cf4
70f2efa
eae25ce
236bae0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
import { CodeError } from '@libp2p/interface' | ||
import { exporter, recursive, type UnixFSEntry } from 'ipfs-unixfs-exporter' | ||
import map from 'it-map' | ||
import { pipe } from 'it-pipe' | ||
import { pack, type TarEntryHeader, type TarImportCandidate } from 'it-tar' | ||
import type { AbortOptions } from '@libp2p/interface' | ||
import type { Blockstore } from 'interface-blockstore' | ||
|
||
const EXPORTABLE = ['file', 'raw', 'directory'] | ||
|
||
function toHeader (file: UnixFSEntry): Partial<TarEntryHeader> & { name: string } { | ||
let mode: number | undefined | ||
let mtime: Date | undefined | ||
|
||
if (file.type === 'file' || file.type === 'directory') { | ||
mode = file.unixfs.mode | ||
mtime = file.unixfs.mtime != null ? new Date(Number(file.unixfs.mtime.secs * 1000n)) : undefined | ||
} | ||
|
||
return { | ||
name: file.path, | ||
mode, | ||
mtime, | ||
size: Number(file.size), | ||
type: file.type === 'directory' ? 'directory' : 'file' | ||
} | ||
} | ||
|
||
function toTarImportCandidate (entry: UnixFSEntry): TarImportCandidate { | ||
if (!EXPORTABLE.includes(entry.type)) { | ||
throw new CodeError('Not a UnixFS node', 'ERR_NOT_UNIXFS') | ||
} | ||
|
||
const candidate: TarImportCandidate = { | ||
header: toHeader(entry) | ||
} | ||
|
||
if (entry.type === 'file' || entry.type === 'raw') { | ||
candidate.body = entry.content() | ||
} | ||
|
||
return candidate | ||
} | ||
|
||
export async function * tarStream (ipfsPath: string, blockstore: Blockstore, options?: AbortOptions): AsyncGenerator<Uint8Array> { | ||
const file = await exporter(ipfsPath, blockstore, options) | ||
|
||
if (file.type === 'file' || file.type === 'raw') { | ||
yield * pipe( | ||
[toTarImportCandidate(file)], | ||
pack() | ||
) | ||
|
||
return | ||
} | ||
|
||
if (file.type === 'directory') { | ||
yield * pipe( | ||
recursive(ipfsPath, blockstore, options), | ||
(source) => map(source, (entry) => toTarImportCandidate(entry)), | ||
pack() | ||
) | ||
|
||
return | ||
} | ||
|
||
throw new CodeError('Not a UnixFS node', 'ERR_NOT_UNIXFS') | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
import { unixfs } from '@helia/unixfs' | ||
import { stop } from '@libp2p/interface' | ||
import { expect } from 'aegir/chai' | ||
import browserReadableStreamToIt from 'browser-readablestream-to-it' | ||
import all from 'it-all' | ||
import last from 'it-last' | ||
import { pipe } from 'it-pipe' | ||
import { extract } from 'it-tar' | ||
import toBuffer from 'it-to-buffer' | ||
import { VerifiedFetch } from '../src/verified-fetch.js' | ||
import { createHelia } from './fixtures/create-offline-helia.js' | ||
import type { Helia } from '@helia/interface' | ||
import type { FileCandidate } from 'ipfs-unixfs-importer' | ||
|
||
describe('tar files', () => { | ||
let helia: Helia | ||
let verifiedFetch: VerifiedFetch | ||
|
||
beforeEach(async () => { | ||
helia = await createHelia() | ||
verifiedFetch = new VerifiedFetch({ | ||
helia | ||
}) | ||
}) | ||
|
||
afterEach(async () => { | ||
await stop(helia, verifiedFetch) | ||
}) | ||
|
||
it('should support fetching a TAR file', async () => { | ||
const file = Uint8Array.from([0, 1, 2, 3, 4]) | ||
const fs = unixfs(helia) | ||
const cid = await fs.addBytes(file) | ||
|
||
const resp = await verifiedFetch.fetch(cid, { | ||
headers: { | ||
accept: 'application/x-tar' | ||
} | ||
}) | ||
expect(resp.status).to.equal(200) | ||
expect(resp.headers.get('content-type')).to.equal('application/x-tar') | ||
expect(resp.headers.get('content-disposition')).to.equal(`attachment; filename="${cid.toString()}.tar"`) | ||
|
||
if (resp.body == null) { | ||
throw new Error('Download failed') | ||
} | ||
|
||
const entries = await pipe( | ||
browserReadableStreamToIt(resp.body), | ||
extract(), | ||
async source => all(source) | ||
) | ||
|
||
expect(entries).to.have.lengthOf(1) | ||
await expect(toBuffer(entries[0].body)).to.eventually.deep.equal(file) | ||
}) | ||
|
||
it('should support fetching a TAR file containing a directory', async () => { | ||
const directory: FileCandidate[] = [{ | ||
path: 'foo.txt', | ||
content: Uint8Array.from([0, 1, 2, 3, 4]) | ||
}, { | ||
path: 'bar.txt', | ||
content: Uint8Array.from([5, 6, 7, 8, 9]) | ||
}, { | ||
path: 'baz/qux.txt', | ||
content: Uint8Array.from([1, 2, 3, 4, 5]) | ||
}] | ||
|
||
const fs = unixfs(helia) | ||
const importResult = await last(fs.addAll(directory, { | ||
wrapWithDirectory: true | ||
})) | ||
|
||
if (importResult == null) { | ||
throw new Error('Import failed') | ||
} | ||
|
||
const resp = await verifiedFetch.fetch(importResult.cid, { | ||
headers: { | ||
accept: 'application/x-tar' | ||
} | ||
}) | ||
expect(resp.status).to.equal(200) | ||
expect(resp.headers.get('content-type')).to.equal('application/x-tar') | ||
expect(resp.headers.get('content-disposition')).to.equal(`attachment; filename="${importResult.cid.toString()}.tar"`) | ||
|
||
if (resp.body == null) { | ||
throw new Error('Download failed') | ||
} | ||
|
||
const entries = await pipe( | ||
browserReadableStreamToIt(resp.body), | ||
extract(), | ||
async source => all(source) | ||
) | ||
|
||
expect(entries).to.have.lengthOf(5) | ||
expect(entries[0]).to.have.nested.property('header.name', importResult.cid.toString()) | ||
|
||
expect(entries[1]).to.have.nested.property('header.name', `${importResult.cid}/${directory[1].path}`) | ||
await expect(toBuffer(entries[1].body)).to.eventually.deep.equal(directory[1].content) | ||
|
||
expect(entries[2]).to.have.nested.property('header.name', `${importResult.cid}/${directory[2].path?.split('/')[0]}`) | ||
|
||
expect(entries[3]).to.have.nested.property('header.name', `${importResult.cid}/${directory[2].path}`) | ||
await expect(toBuffer(entries[3].body)).to.eventually.deep.equal(directory[2].content) | ||
|
||
expect(entries[4]).to.have.nested.property('header.name', `${importResult.cid}/${directory[0].path}`) | ||
await expect(toBuffer(entries[4].body)).to.eventually.deep.equal(directory[0].content) | ||
}) | ||
Comment on lines
+98
to
+111
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the order here seems odd.. should There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Alex let me know it-tar handles ordering and it's stable, so it should be fine. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's some interesting discussion on tar file ordering here: https://unix.stackexchange.com/questions/120143/how-is-the-order-in-which-tar-works-on-files-determined Either way, it's external to |
||
|
||
it('should support fetching a TAR file by format', async () => { | ||
const file = Uint8Array.from([0, 1, 2, 3, 4]) | ||
const fs = unixfs(helia) | ||
const cid = await fs.addBytes(file) | ||
|
||
const resp = await verifiedFetch.fetch(`ipfs://${cid}?format=tar`) | ||
expect(resp.status).to.equal(200) | ||
expect(resp.headers.get('content-type')).to.equal('application/x-tar') | ||
expect(resp.headers.get('content-disposition')).to.equal(`attachment; filename="${cid.toString()}.tar"`) | ||
}) | ||
|
||
it('should support specifying a filename for a TAR file', async () => { | ||
const file = Uint8Array.from([0, 1, 2, 3, 4]) | ||
const fs = unixfs(helia) | ||
const cid = await fs.addBytes(file) | ||
|
||
const resp = await verifiedFetch.fetch(`ipfs://${cid}?filename=foo.bar`, { | ||
headers: { | ||
accept: 'application/x-tar' | ||
} | ||
}) | ||
expect(resp.status).to.equal(200) | ||
expect(resp.headers.get('content-type')).to.equal('application/x-tar') | ||
expect(resp.headers.get('content-disposition')).to.equal('attachment; filename="foo.bar"') | ||
}) | ||
|
||
it('should support fetching a TAR file by format with a filename', async () => { | ||
const file = Uint8Array.from([0, 1, 2, 3, 4]) | ||
const fs = unixfs(helia) | ||
const cid = await fs.addBytes(file) | ||
|
||
const resp = await verifiedFetch.fetch(`ipfs://${cid}?format=tar&filename=foo.bar`) | ||
expect(resp.status).to.equal(200) | ||
expect(resp.headers.get('content-type')).to.equal('application/x-tar') | ||
expect(resp.headers.get('content-disposition')).to.equal('attachment; filename="foo.bar"') | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do we want to squash the bigint from
Exportable.size
to a Number here?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have to convert it because
it-tar
expects the field to be a number.We may lose some precision but
Number.MAX_SAFE_INTEGER
is 9PB so famous last words but I think files of that size may be uncommon.