Skip to content

Commit 325b36f

Browse files
achingbrain2color
andauthored
fix!: return simple stats or extended stats (#760)
Splits the unixfs/mfs stat command return types into two types - "regular" stats (these only return stats available on the root node of the DAG) and "extended" - these collect stats from the DAG, potentially going to the network to fetch missing blocks. There are separate simple and extended types for files and dirs, but raw/leaf nodes only get one type since there are never any nodes linked to from them. As a bonus we can now calculate the size of directories correctly, (as the combination of all child files/directories), assuming all blocks are present in the blockstore or fetchable from the network. Fixes #580 BREAKING CHANGE: Fields that would involve DAG traversal have been removed from the output of `fs.stat` - pass the `extended` option to have them returned --------- Co-authored-by: Daniel Norman <1992255+2color@users.noreply.github.com> Co-authored-by: Daniel N <2color@users.noreply.github.com>
1 parent c0bf36e commit 325b36f

9 files changed

Lines changed: 682 additions & 207 deletions

File tree

packages/interop/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,14 @@
7373
"@helia/unixfs": "^4.0.3",
7474
"@ipld/car": "^5.3.3",
7575
"@ipld/dag-cbor": "^9.2.2",
76+
"@ipld/dag-pb": "^4.1.3",
7677
"@libp2p/crypto": "^5.0.7",
7778
"@libp2p/interface": "^2.2.1",
7879
"@libp2p/kad-dht": "^14.1.3",
7980
"@libp2p/keychain": "^5.0.10",
8081
"@libp2p/peer-id": "^5.0.8",
8182
"@libp2p/websockets": "^9.0.13",
83+
"@multiformats/multiaddr": "^12.4.0",
8284
"@multiformats/sha3": "^3.0.2",
8385
"aegir": "^45.1.1",
8486
"helia": "^5.3.0",

packages/interop/src/unixfs-files.spec.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
/* eslint-env mocha */
22

33
import { unixfs } from '@helia/unixfs'
4+
import * as dagPb from '@ipld/dag-pb'
5+
import { multiaddr } from '@multiformats/multiaddr'
46
import { expect } from 'aegir/chai'
57
import { fixedSize } from 'ipfs-unixfs-importer/chunker'
68
import { balanced } from 'ipfs-unixfs-importer/layout'
9+
import drain from 'it-drain'
10+
import last from 'it-last'
711
import { CID } from 'multiformats/cid'
12+
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
813
import { createHeliaNode } from './fixtures/create-helia.js'
914
import { createKuboNode } from './fixtures/create-kubo.js'
1015
import type { AddOptions, UnixFS } from '@helia/unixfs'
1116
import type { HeliaLibp2p } from 'helia'
12-
import type { ByteStream } from 'ipfs-unixfs-importer'
17+
import type { ByteStream, ImportCandidateStream } from 'ipfs-unixfs-importer'
1318
import type { KuboNode } from 'ipfsd-ctl'
1419
import type { AddOptions as KuboAddOptions } from 'kubo-rpc-client'
1520

@@ -24,12 +29,32 @@ describe('@helia/unixfs - files', () => {
2429
return cid
2530
}
2631

32+
async function importDirectoryToHelia (data: ImportCandidateStream, opts?: Partial<AddOptions>): Promise<CID> {
33+
const result = await last(unixFs.addAll(data, opts))
34+
35+
if (result == null) {
36+
throw new Error('Nothing imported')
37+
}
38+
39+
return CID.parse(result.cid.toString())
40+
}
41+
2742
async function importToKubo (data: ByteStream, opts?: KuboAddOptions): Promise<CID> {
2843
const result = await kubo.api.add(data, opts)
2944

3045
return CID.parse(result.cid.toString())
3146
}
3247

48+
async function importDirectoryToKubo (data: ImportCandidateStream, opts?: KuboAddOptions): Promise<CID> {
49+
const result = await last(kubo.api.addAll(data, opts))
50+
51+
if (result == null) {
52+
throw new Error('Nothing imported')
53+
}
54+
55+
return CID.parse(result.cid.toString())
56+
}
57+
3358
async function expectSameCid (data: () => ByteStream, heliaOpts: Partial<AddOptions> = {}, kuboOpts: KuboAddOptions = {}): Promise<void> {
3459
const heliaCid = await importToHelia(data(), {
3560
// these are the default kubo options
@@ -85,4 +110,103 @@ describe('@helia/unixfs - files', () => {
85110

86111
await expectSameCid(candidate)
87112
})
113+
114+
it('should return the same directory stats', async () => {
115+
const candidates = [{
116+
path: '/foo1.txt',
117+
content: uint8ArrayFromString('Hello World!')
118+
}, {
119+
path: '/foo2.txt',
120+
content: uint8ArrayFromString('Hello World!')
121+
}]
122+
123+
const heliaCid = await importDirectoryToHelia(candidates, {
124+
wrapWithDirectory: true
125+
})
126+
const kuboCid = await importDirectoryToKubo(candidates, {
127+
cidVersion: 1,
128+
chunker: `size-${1024 * 1024}`,
129+
rawLeaves: true,
130+
wrapWithDirectory: true
131+
})
132+
133+
expect(heliaCid.toString()).to.equal(kuboCid.toString())
134+
135+
const heliaStat = await unixFs.stat(heliaCid, {
136+
extended: true
137+
})
138+
const kuboStat = await kubo.api.files.stat(`/ipfs/${kuboCid}`, {
139+
withLocal: true
140+
})
141+
142+
expect(heliaStat.dagSize.toString()).to.equal(kuboStat.cumulativeSize.toString())
143+
expect(heliaStat.dagSize.toString()).to.equal(kuboStat.sizeLocal?.toString())
144+
145+
// +1 because kubo doesn't count the root directory block
146+
expect(heliaStat.blocks.toString()).to.equal((kuboStat.blocks + 1).toString())
147+
})
148+
149+
it('fetches missing blocks during stat', async () => {
150+
const chunkSize = 1024 * 1024
151+
const size = chunkSize * 10
152+
153+
const candidate = (): ByteStream => (async function * () {
154+
for (let i = 0; i < size; i += chunkSize) {
155+
yield new Uint8Array(new Array(chunkSize).fill(0).map((val, index) => {
156+
return Math.floor(Math.random() * 256)
157+
}))
158+
}
159+
}())
160+
161+
const largeFileCid = await importToKubo(candidate())
162+
const info = await kubo.info()
163+
164+
await helia.libp2p.dial(info.multiaddrs.map(ma => multiaddr(ma)))
165+
166+
// pull all blocks from kubo
167+
await drain(unixFs.cat(largeFileCid))
168+
169+
// check the root block
170+
const block = await helia.blockstore.get(largeFileCid)
171+
const node = dagPb.decode(block)
172+
173+
expect(node.Links).to.have.lengthOf(40)
174+
175+
const stats = await unixFs.stat(largeFileCid, {
176+
extended: true
177+
})
178+
179+
expect(stats.unixfs?.fileSize()).to.equal(10485760n)
180+
expect(stats.blocks).to.equal(41n)
181+
expect(stats.dagSize).to.equal(10488250n)
182+
expect(stats.localSize).to.equal(10485760n)
183+
184+
// remove one of the blocks so we now have an incomplete DAG
185+
await helia.blockstore.delete(node.Links[0].Hash)
186+
187+
// block count and local file/dag sizes should be smaller
188+
const updatedStats = await unixFs.stat(largeFileCid, {
189+
extended: true,
190+
offline: true
191+
})
192+
193+
expect(updatedStats.unixfs?.fileSize()).to.equal(10485760n)
194+
expect(updatedStats.blocks).to.equal(40n)
195+
expect(updatedStats.dagSize).to.equal(10226092n)
196+
expect(updatedStats.localSize).to.equal(10223616n)
197+
198+
await new Promise<void>((resolve) => {
199+
setTimeout(() => {
200+
resolve()
201+
}, 1_000)
202+
})
203+
204+
// block count and local file/dag sizes should be smaller
205+
const finalStats = await unixFs.stat(largeFileCid, {
206+
extended: true
207+
})
208+
209+
// should have fetched missing block from Kubo
210+
expect(finalStats).to.deep.equal(stats, 'did not fetch missing block')
211+
})
88212
})

packages/mfs/src/index.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { Key } from 'interface-datastore'
3434
import { UnixFS as IPFSUnixFS, type Mtime } from 'ipfs-unixfs'
3535
import { CID } from 'multiformats/cid'
3636
import { basename } from './utils/basename.js'
37-
import type { AddOptions, CatOptions, ChmodOptions, CpOptions, LsOptions, MkdirOptions as UnixFsMkdirOptions, RmOptions as UnixFsRmOptions, StatOptions, TouchOptions, UnixFS, UnixFSStats } from '@helia/unixfs'
37+
import type { AddOptions, CatOptions, ChmodOptions, CpOptions, LsOptions, MkdirOptions as UnixFsMkdirOptions, RmOptions as UnixFsRmOptions, StatOptions, TouchOptions, UnixFS, FileStats, DirectoryStats, RawStats, ExtendedStatOptions, ExtendedFileStats, ExtendedDirectoryStats, ExtendedRawStats } from '@helia/unixfs'
3838
import type { AbortOptions } from '@libp2p/interface'
3939
import type { Blockstore } from 'interface-blockstore'
4040
import type { Datastore } from 'interface-datastore'
@@ -213,7 +213,8 @@ export interface MFS {
213213
* console.info(stats)
214214
* ```
215215
*/
216-
stat(path: string, options?: Partial<StatOptions>): Promise<UnixFSStats>
216+
stat(path: string, options?: StatOptions): Promise<FileStats | DirectoryStats | RawStats>
217+
stat(path: string, options?: ExtendedStatOptions): Promise<ExtendedFileStats | ExtendedDirectoryStats | ExtendedRawStats>
217218

218219
/**
219220
* Update the mtime of a UnixFS DAG in your MFS.
@@ -438,7 +439,9 @@ class DefaultMFS implements MFS {
438439
this.root = await this.#persistPath(trail, options)
439440
}
440441

441-
async stat (path: string, options?: Partial<StatOptions>): Promise<UnixFSStats> {
442+
async stat (path: string, options?: StatOptions): Promise<FileStats | DirectoryStats | RawStats>
443+
async stat (path: string, options?: ExtendedStatOptions): Promise<ExtendedFileStats | ExtendedDirectoryStats | ExtendedRawStats>
444+
async stat (path: string, options?: StatOptions | ExtendedStatOptions): Promise<FileStats | DirectoryStats | RawStats | ExtendedFileStats | ExtendedDirectoryStats | ExtendedRawStats> {
442445
const root = await this.#getRootCID()
443446

444447
const trail = await this.#walkPath(root, path, {
@@ -453,9 +456,7 @@ class DefaultMFS implements MFS {
453456
throw new DoesNotExistError()
454457
}
455458

456-
return this.unixfs.stat(finalEntry.cid, {
457-
...options
458-
})
459+
return this.unixfs.stat(finalEntry.cid, options)
459460
}
460461

461462
async touch (path: string, options?: Partial<TouchOptions>): Promise<void> {

0 commit comments

Comments
 (0)