diff --git a/examples/audio/package.json b/examples/audio/package.json index a4b978e..3d6933a 100644 --- a/examples/audio/package.json +++ b/examples/audio/package.json @@ -1,7 +1,6 @@ { - "name": "demo", + "name": "audio", "type": "module", - "version": "1.0.0", "private": true, "description": "", "author": "Steven Vandevelde (tokono.ma)", diff --git a/examples/demo/package.json b/examples/demo/package.json index 3a91669..b414c44 100644 --- a/examples/demo/package.json +++ b/examples/demo/package.json @@ -1,7 +1,6 @@ { "name": "demo", "type": "module", - "version": "1.0.0", "private": true, "description": "", "author": "Steven Vandevelde (tokono.ma)", diff --git a/examples/web3storage/package.json b/examples/web3storage/package.json index 65b2542..0874cef 100644 --- a/examples/web3storage/package.json +++ b/examples/web3storage/package.json @@ -1,7 +1,6 @@ { - "name": "demo", + "name": "web3storage", "type": "module", - "version": "1.0.0", "private": true, "description": "", "author": "Steven Vandevelde (tokono.ma)", diff --git a/examples/web3storage/rsbuild.config.ts b/examples/web3storage/rsbuild.config.ts index 7d9c18b..e8a32fc 100644 --- a/examples/web3storage/rsbuild.config.ts +++ b/examples/web3storage/rsbuild.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "@rsbuild/core"; +import { defineConfig } from '@rsbuild/core' export default defineConfig({ html: { - template: "./src/index.html", + template: './src/index.html', }, -}); +}) diff --git a/packages/nest/README.md b/packages/nest/README.md index 5caeb4c..01c57f5 100644 --- a/packages/nest/README.md +++ b/packages/nest/README.md @@ -8,10 +8,12 @@ A layer around the `wnfs` package that provides a `FileSystem` class, a root tre - A file system class that allows for an easy-to-use mutable API. - A root tree, holding references to all the needed individual parts (public fs, private forest, exchange, etc) -- A unix-fs compatibility layer for the public file system (allows for public files to be viewed through, for example, IPFS gateways) - A mounting system for private nodes, mount specific paths. +- A unix-fs compatibility layer for the public file system (allows for public files to be viewed through, for example, IPFS gateways) +- Private data sharing + helpers - Provides a transaction system, rewinding the state if an error occurs. -- Creates a private forest automatically with a RSA modules using the Web Crypto API (supported on multiple platforms) +- Creates a private forest automatically with a RSA modulus using the Web Crypto API (supported on multiple platforms) +- In addition to the default symmetric key, use an RSA-OAEP asymmetric key to mount a private node (essentially sharing to self). Can be used to load a private directory, or file, using a passkey + the PRF extension. - Ability to verify commits to the file system. If a commit, aka. modification, is not verified, it will result in a no-op. - And more: typed paths, events, path helpers, data casting, … @@ -23,17 +25,15 @@ pnpm install @wnfs-wg/nest ## Usage +Scenario 1:
+🚀 Create a new file system, create a new file and read it back. + ```ts import { FileSystem, Path } from '@wnfs-wg/nest' // Provide some block store of the `Blockstore` type from the `interface-blockstore` package import { IDBBlockstore } from 'blockstore-idb' -``` - -Scenario 1:
-🚀 Create a new file system, create a new file and read it back. -```ts const blockstore = new IDBBlockstore('path/to/store') await blockstore.open() @@ -42,14 +42,14 @@ const fs = await FileSystem.create({ }) // Create the private node of which we'll keep the encryption key around. -const { capsuleKey } = await fs.mountPrivateNode({ +const { capsuleKey } = await fs.createPrivateNode({ path: Path.root(), // ie. root private directory }) // Write & Read -await fs.write(Path.file('private', 'file'), 'utf8', '🪺') +await fs.write(['private', 'file'], 'utf8', '🪺') -const contents = await fs.read(Path.file('private', 'file'), 'utf8') +const contents = await fs.read(['private', 'file'], 'utf8') ``` Scenario 2:
@@ -66,7 +66,7 @@ of our root tree, the pointer to our file system. let fsPointer: CID = await fs.calculateDataRoot() // When we make a modification to the file system a verification is performed. -await fs.write(Path.file('private', 'file'), 'utf8', '🪺') +await fs.write(['private', 'file'], 'utf8', '🪺') // If the commit is approved, the changes are reflected in the file system and // the `commit` and `publish` events are emitted. @@ -120,6 +120,59 @@ fs.rename fs.write ``` +## Identifier + +```ts +fs.identifier() +fs.assignIdentifier('did') +``` + +## Private Data Sharing + +Flow: + +1. The receiver of a share register their exchange key. An app could do this automatically when the app starts, or at some other time. +2. The data root of the receiver is passed to the sharer. Ideally this is done through some naming system. For example, you use DNS to map a username to the data root (eg. `TXT file-system.tokono.ma` could resolve to the data root, a CID). That said, this could also be done without a naming system, maybe by presenting a QR code. +3. Make sure the sharer's file system has an identity assigned. +4. The sharer creates the share. +5. This step is the reverse of step 2, where we pass the sharer's data root to the receiver. +6. Use the shared item. + +```ts +// Step 1 & 2 (Receiver) +const { dataRoot } = await fs.registerExchangeKey('key-id', publicKey) +const receiverDataRoot = dataRoot + +// Step 3, 4 & 5 (Sharer) +await fs.assignIdentifier('did') + +const { dataRoot } = await fs.share(pathToPrivateItem, receiverDataRoot) +const sharerDataRoot = dataRoot + +// Step 6 (Receiver) +const share = await fs.receive(sharerDataRoot, { publicKey, privateKey }) + +await share.read('utf8') +``` + +## Manage private node using exchange key pair + +Instead of keeping the (symmetric) capsule key around we can use an (asymmetric) exchange key pair to mount a private node. This basically creates a share for ourselves. + +```ts +// 🚀 Create & mount +await fs.createPrivateNode({ + path: Path.root(), + exchangeKeyPair: { publicKey, privateKey }, // 🔑 Pass in key pair here +}) + +// 🧳 Load +await fs.mountPrivateNode({ + path: Path.root(), + exchangeKeyPair: { publicKey, privateKey }, +}) +``` + ## Transactions ```ts diff --git a/packages/nest/package.json b/packages/nest/package.json index 71e39ea..9d78239 100644 --- a/packages/nest/package.json +++ b/packages/nest/package.json @@ -36,6 +36,10 @@ "types": "./dist/src/events.d.ts", "default": "./dist/src/events.js" }, + "./exchange-key": { + "types": "./dist/src/exchange-key.d.ts", + "default": "./dist/src/exchange-key.js" + }, "./path": { "types": "./dist/src/path.d.ts", "default": "./dist/src/path.js" @@ -80,6 +84,9 @@ "events": [ "dist/src/events" ], + "exchange-key": [ + "dist/src/exchange-key" + ], "path": [ "dist/src/path" ], @@ -99,7 +106,7 @@ "src" ], "scripts": { - "lint": "tsc --build && eslint . --quiet --ignore-pattern='README.md' && prettier --check '**/*.{js,ts,yml,json}' --ignore-path ../../.gitignore", + "lint": "tsc --build && eslint . --quiet && prettier --check '**/*.{js,ts,yml,json}' --ignore-path ../../.gitignore", "build": "tsc --build", "test": "pnpm run test:node && pnpm run test:browser", "test:node": "mocha 'test/**/!(*.browser).test.ts' --bail --timeout 30000", @@ -142,7 +149,8 @@ "mocha": true }, "ignorePatterns": [ - "dist" + "dist", + "README.md" ], "rules": { "@typescript-eslint/no-unused-vars": [ diff --git a/packages/nest/src/class.ts b/packages/nest/src/class.ts index dc75d3a..eb20354 100644 --- a/packages/nest/src/class.ts +++ b/packages/nest/src/class.ts @@ -43,6 +43,8 @@ import * as Rng from './rng.js' import * as Store from './store.js' import type { RootTree } from './root-tree.js' +import type { Share } from './share.js' + import type { Partition, Partitioned, @@ -51,10 +53,11 @@ import type { Public, } from './path.js' -import { searchLatest } from './common.js' -import { partition as determinePartition, findPrivateNode } from './mounts.js' import { TransactionContext } from './transaction.js' import { BasicRootTree } from './root-tree/basic.js' +import { searchLatest } from './common.js' +import { partition as determinePartition, findPrivateNode } from './mounts.js' +import { loadShare } from './sharing.js' // OPTIONS @@ -80,7 +83,13 @@ export class FileSystem { #privateNodes: MountedPrivateNodes = {} #rootTree: RootTree - /** @hidden */ + /** + * @param blockstore + * @param onCommit + * @param rootTree + * @param settleTimeBeforePublish + * @hidden + */ constructor( blockstore: Blockstore, onCommit: CommitVerifier | undefined, @@ -113,6 +122,7 @@ export class FileSystem { /** * Creates a file system with an empty public tree & an empty private tree at the root. * + * @param opts * @group 🪺 :: START HERE */ static async create(opts: FileSystemOptions): Promise { @@ -131,6 +141,8 @@ export class FileSystem { /** * Loads an existing file system from a CID. * + * @param cid + * @param opts * @group 🪺 :: START HERE */ static async fromCID(cid: CID, opts: FileSystemOptions): Promise { @@ -156,6 +168,8 @@ export class FileSystem { /** * {@inheritDoc Emittery.on} * + * @param eventName + * @param listener * @group Events */ on = ( @@ -166,6 +180,7 @@ export class FileSystem { /** * {@inheritDoc Emittery.onAny} * + * @param listener * @group Events */ onAny = ( @@ -178,6 +193,8 @@ export class FileSystem { /** * {@inheritDoc Emittery.off} * + * @param eventName + * @param listener * @group Events */ off = ( @@ -190,6 +207,7 @@ export class FileSystem { /** * {@inheritDoc Emittery.offAny} * + * @param listener * @group Events */ offAny = ( @@ -204,6 +222,7 @@ export class FileSystem { /** * {@inheritDoc Emittery.once} * + * @param eventName * @group Events */ once = ( @@ -222,6 +241,7 @@ export class FileSystem { /** * {@inheritDoc Emittery.events} * + * @param eventName * @group Events */ events = ( @@ -232,162 +252,312 @@ export class FileSystem { // ------ /** - * Mount a private node onto the file system. + * Create a new private node and mount it. * + * @param node + * @param node.path + * @param node.exchangeKeyPair + * @param node.exchangeKeyPair.publicKey + * @param node.exchangeKeyPair.privateKey + * @param mutationOptions * @group Mounting */ - async mountPrivateNode(node: { - path: Path.Distinctive - capsuleKey?: Uint8Array - }): Promise<{ - path: Path.Distinctive + async createPrivateNode( + node: { + path: Path.Segments + exchangeKeyPair: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey + } + kind?: Path.Kind + }, + mutationOptions?: MutationOptions + ): Promise<{ + path: Path.Partitioned + capsuleKey: Uint8Array + shareId: string + }> + async createPrivateNode( + node: { + path: Path.Segments + kind?: Path.Kind + }, + mutationOptions?: MutationOptions + ): Promise<{ + path: Path.Partitioned capsuleKey: Uint8Array + }> + async createPrivateNode( + node: { + path: Path.Segments + exchangeKeyPair?: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey + } + kind?: Path.Kind + }, + mutationOptions?: MutationOptions + ): Promise<{ + path: Path.Partitioned + capsuleKey: Uint8Array + shareId?: string }> { - const mounts = await this.mountPrivateNodes([node]) - return mounts[0] + const { kind, path } = node + const absolutePosixPath = Path.toPosix(path, { absolute: true }) + + if (this.#privateNodes[absolutePosixPath] !== undefined) { + throw new Error( + 'A private node is already mounted at this path, unmount it first if you want to replace it.' + ) + } + + // Share to self, pt. 1 + if (node.exchangeKeyPair !== undefined) { + const isRegistered = await this.isExchangeKeyRegistered( + node.exchangeKeyPair.publicKey + ) + + if (!isRegistered) + throw new Error( + 'Register the exchange key first using `registerExchange`' + ) + } + + // Create + const privateNode = + kind === Path.Kind.File + ? new PrivateFile( + this.#rootTree.privateForest().emptyName(), + new Date(), + this.#rng + ).asNode() + : new PrivateDirectory( + this.#rootTree.privateForest().emptyName(), + new Date(), + this.#rng + ).asNode() + + // Store + const storeResult = await privateNode.store( + this.#rootTree.privateForest(), + Store.wnfs(this.#blockstore), + this.#rng + ) + + const [accessKey, privateForest] = storeResult + + // Update root tree + const pathWithPartition = Path.withPartition('private', path) + const modification: Modification = { + path: pathWithPartition, + type: 'added-or-updated', + } + + this.#rootTree = await this.#rootTree.replacePrivateForest( + privateForest as PrivateForest, + [modification] + ) + + // Mount + this.#privateNodes = { + ...this.#privateNodes, + [absolutePosixPath]: { node: privateNode, path }, + } + + // Emit events + const dataRoot = await this.calculateDataRoot() + + await this.#eventEmitter.emit('commit', { + dataRoot, + modifications: [modification], + }) + + // Publish + if ( + mutationOptions?.skipPublish === false || + mutationOptions?.skipPublish === undefined + ) { + await this.#publish(dataRoot, [modification]) + } + + // Share to self, pt. 2 + if (node.exchangeKeyPair !== undefined) { + const { shareId } = await this.share(pathWithPartition, dataRoot, { + mutationOptions, + }) + + return { + path: pathWithPartition, + capsuleKey: accessKey.toBytes(), + shareId, + } + } + + // Fin + return { + path: pathWithPartition, + capsuleKey: accessKey.toBytes(), + } + } + + /** + * Mount a private node onto the file system. + * + * @param node + * @group Mounting + */ + async mountPrivateNode( + node: + | { + path: Path.Segments + capsuleKey: Uint8Array + } + | { + path: Path.Segments + exchangeKeyPair: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey + } + shareId?: string + } + ): Promise { + await this.mountPrivateNodes([node]) } /** * Mount private nodes onto the file system. * - * When a `capsuleKey` is not given, - * it will create the given path instead of trying to load it. + * This supports two scenarios: + * - Load a private node using a capsule key + * - Load a private node using an exchange key pair (this is a share made to one of your own exchange keys) * + * @param nodes * @group Mounting */ async mountPrivateNodes( - nodes: Array<{ - path: Path.Distinctive - capsuleKey?: Uint8Array - }> - ): Promise< - Array<{ - path: Path.Distinctive - capsuleKey: Uint8Array - }> - > { - const newNodes = await Promise.all( - nodes.map( - async ({ path, capsuleKey }): Promise<[string, MountedPrivateNode]> => { - let privateNode: PrivateNode - - if (capsuleKey === null || capsuleKey === undefined) { - privateNode = Path.isFile(path) - ? new PrivateFile( - this.#rootTree.privateForest().emptyName(), - new Date(), - this.#rng - ).asNode() - : new PrivateDirectory( - this.#rootTree.privateForest().emptyName(), - new Date(), - this.#rng - ).asNode() - } else { - const accessKey = AccessKey.fromBytes(capsuleKey) - privateNode = await PrivateNode.load( - accessKey, - this.#rootTree.privateForest(), - Store.wnfs(this.#blockstore) - ) + nodes: Array< + | { + path: Path.Segments + capsuleKey: Uint8Array + } + | { + path: Path.Segments + exchangeKeyPair: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey } + shareId?: string + } + > + ): Promise { + let newNodes: Array<[string, MountedPrivateNode]> = [] + + await Promise.all( + nodes.map(async (args): Promise => { + let privateNode: PrivateNode | undefined + + const { path } = args + + if ('capsuleKey' in args) { + const accessKey = AccessKey.fromBytes(args.capsuleKey) + privateNode = await PrivateNode.load( + accessKey, + this.#rootTree.privateForest(), + Store.wnfs(this.#blockstore) + ) + } else if ('exchangeKeyPair' in args) { + privateNode = await loadShare( + await this.calculateDataRoot(), + args.exchangeKeyPair, + { + shareId: args.shareId, + sharerBlockstore: this.#blockstore, + } + ).then((a) => a.sharedNode) + } - return [ - // Use absolute paths so that you can retrieve the root: privateNodes["/"] - Path.toPosix(path, { absolute: true }), - { node: privateNode, path }, + if (privateNode !== undefined) + newNodes = [ + ...newNodes, + [ + // Use absolute paths so that we can retrieve the root: privateNodes["/"] + Path.toPosix(path, { absolute: true }), + { node: privateNode, path }, + ], ] - } - ) + }) ) this.#privateNodes = { ...this.#privateNodes, ...Object.fromEntries(newNodes), } - - return await Promise.all( - newNodes.map(async ([_, n]: [string, MountedPrivateNode]) => { - const storeResult = await n.node.store( - this.#rootTree.privateForest(), - Store.wnfs(this.#blockstore), - this.#rng - ) - const [accessKey, privateForest] = storeResult - - this.#rootTree = await this.#rootTree.replacePrivateForest( - privateForest as PrivateForest, - [ - { - path: Path.withPartition('private', n.path), - type: 'added-or-updated', - }, - ] - ) - - return { - path: n.path, - capsuleKey: accessKey.toBytes(), - } - }) - ) } /** * Unmount a private node from the file system. * + * @param path * @group Mounting */ - unmountPrivateNode(path: Path.Distinctive): void { + unmountPrivateNode(path: Path.Segments): void { // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete this.#privateNodes[Path.toPosix(path)] + delete this.#privateNodes[Path.toPosix(path, { absolute: true })] } // QUERY // ----- - /** @group Querying */ - async contentCID( - path: Path.File> - ): Promise { + /** + * @param path + * @group Querying + */ + async contentCID(path: Partitioned): Promise { return await this.#transactionContext().contentCID(path) } - /** @group Querying */ - async capsuleCID( - path: Path.Distinctive> - ): Promise { + /** + * @param path + * @group Querying + */ + async capsuleCID(path: Partitioned): Promise { return await this.#transactionContext().capsuleCID(path) } - /** @group Querying */ + /** + * @param path + * @group Querying + */ async capsuleKey( - path: Path.Distinctive> + path: Partitioned ): Promise { return await this.#transactionContext().capsuleKey(path) } - /** @group Querying */ - async exists( - path: Path.Distinctive> - ): Promise { + /** + * @param path + * @group Querying + */ + async exists(path: Partitioned): Promise { return await this.#transactionContext().exists(path) } /** @group Querying */ async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions: { withItemKind: true } ): Promise async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions: { withItemKind: false } ): Promise + async listDirectory(path: Partitioned): Promise async listDirectory( - path: Path.Directory> - ): Promise + path: Partitioned, + listOptions?: { withItemKind: boolean } + ): Promise async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions?: { withItemKind: boolean } ): Promise { return await this.#transactionContext().listDirectory(path, listOptions) @@ -399,7 +569,7 @@ export class FileSystem { /** @group Querying */ async read( path: - | Path.File> + | PartitionedNonEmpty | { contentCID: CID } | { capsuleCID: CID } | { @@ -410,7 +580,7 @@ export class FileSystem { ): Promise> async read( path: - | Path.File> + | PartitionedNonEmpty | { contentCID: CID } | { capsuleCID: CID } | { @@ -426,8 +596,11 @@ export class FileSystem { ) } - /** @group Querying */ - async size(path: Path.File>): Promise { + /** + * @param path + * @group Querying + */ + async size(path: PartitionedNonEmpty): Promise { return await this.#transactionContext().size(path) } @@ -436,15 +609,13 @@ export class FileSystem { /** @group Mutating */ async copy( - from: Path.Distinctive>, - to: Path.File> | Path.Directory>, + from: PartitionedNonEmpty, + to: Partitioned, mutationOptions?: MutationOptions ): Promise> async copy( - from: Path.Distinctive>, - to: - | Path.File> - | Path.Directory>, + from: PartitionedNonEmpty, + to: Partitioned, mutationOptions: MutationOptions = {} ): Promise> { return await this.#infusedTransaction( @@ -461,19 +632,14 @@ export class FileSystem { /** @group Mutating */ async createDirectory

( - path: Path.Directory>, + path: PartitionedNonEmpty

, mutationOptions?: MutationOptions - ): Promise< - MutationResult> }> - > + ): Promise }>> async createDirectory( - path: Path.Directory>, + path: PartitionedNonEmpty, mutationOptions: MutationOptions = {} ): Promise< - MutationResult< - Partition, - { path: Path.Directory> } - > + MutationResult }> > { let finalPath = path @@ -494,23 +660,18 @@ export class FileSystem { /** @group Mutating */ async createFile

( - path: Path.File>, + path: PartitionedNonEmpty

, dataType: DataType, data: DataForType, mutationOptions?: MutationOptions - ): Promise< - MutationResult> }> - > + ): Promise }>> async createFile( - path: Path.File>, + path: PartitionedNonEmpty, dataType: DataType, data: DataForType, mutationOptions: MutationOptions = {} ): Promise< - MutationResult< - Partition, - { path: Path.File> } - > + MutationResult }> > { let finalPath = path @@ -531,11 +692,11 @@ export class FileSystem { /** @group Mutating */ async ensureDirectory

( - path: Path.Directory>, + path: PartitionedNonEmpty

, mutationOptions?: MutationOptions ): Promise> async ensureDirectory( - path: Path.Directory>, + path: PartitionedNonEmpty, mutationOptions: MutationOptions = {} ): Promise> { return await this.#infusedTransaction( @@ -552,15 +713,13 @@ export class FileSystem { /** @group Mutating */ async move( - from: Path.Distinctive>, - to: Path.File> | Path.Directory>, + from: PartitionedNonEmpty, + to: PartitionedNonEmpty | Partitioned, mutationOptions?: MutationOptions ): Promise> async move( - from: Path.Distinctive>, - to: - | Path.File> - | Path.Directory>, + from: PartitionedNonEmpty, + to: PartitionedNonEmpty | Partitioned, mutationOptions: MutationOptions = {} ): Promise> { return await this.#infusedTransaction( @@ -575,9 +734,13 @@ export class FileSystem { /** @group Mutating */ mv = this.move // eslint-disable-line @typescript-eslint/unbound-method - /** @group Mutating */ + /** + * @param path + * @param mutationOptions + * @group Mutating + */ async remove( - path: Path.Distinctive>, + path: PartitionedNonEmpty, mutationOptions: MutationOptions = {} ): Promise { const transactionResult = await this.transaction(async (t) => { @@ -598,12 +761,12 @@ export class FileSystem { /** @group Mutating */ async rename

( - path: Path.Distinctive>, + path: PartitionedNonEmpty

, newName: string, mutationOptions?: MutationOptions ): Promise> async rename( - path: Path.Distinctive>, + path: PartitionedNonEmpty, newName: string, mutationOptions: MutationOptions = {} ): Promise> { @@ -618,13 +781,13 @@ export class FileSystem { /** @group Mutating */ async write

( - path: Path.File>, + path: PartitionedNonEmpty

, dataType: DataType, data: DataForType, mutationOptions?: MutationOptions ): Promise> async write( - path: Path.File>, + path: PartitionedNonEmpty, dataType: DataType, data: DataForType, mutationOptions: MutationOptions = {} @@ -638,10 +801,165 @@ export class FileSystem { ) } + // IDENTIFIER + // ---------- + + async assignIdentifier( + did: string, + mutationOptions: MutationOptions = {} + ): Promise<{ dataRoot: CID }> { + const transactionResult = await this.transaction(async (t) => { + await t.assignIdentifier(did) + }, mutationOptions) + + if (transactionResult === 'no-op') { + throw new Error( + 'The transaction was a no-op, most likely as a result of the commit not being approved by the `onCommit` verifier.' + ) + } + + const dataRoot = transactionResult.dataRoot + return { dataRoot } + } + + identifier(): string | undefined { + return this.#rootTree.did() + } + + // SHARING + // ------- + + /** + * Check if an exchange key was already registered. + * + * @param exchangePublicKey + * @group Sharing + */ + async isExchangeKeyRegistered( + exchangePublicKey: CryptoKey | Uint8Array + ): Promise { + return await this.#transactionContext().isExchangeKeyRegistered( + exchangePublicKey + ) + } + + /** + * Load a shared item. + * + * NOTE: A share can only be received if the exchange key was registered + * and the receiver is in possession of the associated private key. + * + * @param sharerDataRoot The data root CID from the sharer + * @param exchangeKeyPair A RSA-OAEP-256 key pair + * @param exchangeKeyPair.publicKey A RSA-OAEP-256 public key in the form of a `CryptoKey` or its modulus bytes + * @param exchangeKeyPair.privateKey A RSA-OAEP-256 private key in the form of a `CryptoKey` + * @param opts Optional overrides + * @param opts.shareId Specify what shareId to use, otherwise this'll load the last share that was made to the given exchange key. + * @param opts.sharerBlockstore Specify what blockstore to use to load the sharer's file system. + * @param opts.sharerRootTreeClass Specify what root tree class was used for the sharer's file system. + * + * @group Sharing + */ + async receive( + sharerDataRoot: CID, + exchangeKeyPair: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey + }, + opts: { + shareId?: string + sharerBlockstore?: Blockstore + sharerRootTreeClass?: typeof RootTree + } = {} + ): Promise { + return await this.#transactionContext().receive( + sharerDataRoot, + exchangeKeyPair, + opts + ) + } + + /** + * Register an exchange key. + * + * @param name A name for the key (using an existing name overrides the old key) + * @param exchangePublicKey A RSA-OAEP-256 public key in the form of a `CryptoKey` or its modulus bytes + * @param mutationOptions Mutation options + * + * @group Sharing + */ + async registerExchangeKey( + name: string, + exchangePublicKey: CryptoKey | Uint8Array, + mutationOptions: MutationOptions = {} + ): Promise<{ dataRoot: CID }> { + const transactionResult = await this.transaction(async (t) => { + await t.registerExchangeKey(name, exchangePublicKey) + }, mutationOptions) + + if (transactionResult === 'no-op') { + throw new Error( + 'The transaction was a no-op, most likely as a result of the commit not being approved by the `onCommit` verifier.' + ) + } + + const dataRoot = transactionResult.dataRoot + return { dataRoot } + } + + /** + * Share a private file or directory. + * + * NOTE: A share can only be received if the exchange key was registered + * and the receiver is in possession of the associated private key. + * + * @param path Path to the private file or directory to share (with 'private' prefix) + * @param receiverDataRoot Data root CID of the receiver + * @param opts Optional overrides + * @param opts.receiverBlockstore Specify what blockstore to use to load the receiver's file system + * @param opts.receiverRootTreeClass Specify what root tree class was used for the receiver's file system + * @param opts.mutationOptions Mutation options + * + * @group Sharing + */ + async share( + path: Partitioned, + receiverDataRoot: CID, + opts: { + mutationOptions?: MutationOptions + receiverBlockstore?: Blockstore + receiverRootTreeClass?: typeof RootTree + } = {} + ): Promise<{ shareId: string } & MutationResult> { + let shareId: string | undefined + + const result = await this.#infusedTransaction( + async (t) => { + const shareResult = await t.share(path, receiverDataRoot, opts) + shareId = shareResult.shareId + }, + path, + opts.mutationOptions + ) + + if (shareId === undefined) { + throw new Error('`shareId` was not set') + } + + return { + ...result, + shareId, + } + } + // TRANSACTIONS // ------------ - /** @group Transacting */ + /** + * @param handler + * @param mutationOptions + * @group Transacting + */ async transaction( handler: (t: TransactionContext) => Promise, mutationOptions: MutationOptions = {} @@ -701,22 +1019,22 @@ export class FileSystem { async #infusedTransaction( handler: (t: TransactionContext) => Promise, - path: Path.Distinctive>, + path: Partitioned, mutationOptions?: MutationOptions ): Promise async #infusedTransaction( handler: (t: TransactionContext) => Promise, - path: Path.Distinctive>, + path: Partitioned, mutationOptions?: MutationOptions ): Promise async #infusedTransaction( handler: (t: TransactionContext) => Promise, - path: Path.Distinctive>, + path: Partitioned, mutationOptions?: MutationOptions ): Promise> async #infusedTransaction( handler: (t: TransactionContext) => Promise, - path: Path.Distinctive>, + path: Partitioned, mutationOptions: MutationOptions = {} ): Promise> { const transactionResult = await this.transaction(handler, mutationOptions) diff --git a/packages/nest/src/common.ts b/packages/nest/src/common.ts index a2eb251..abeb4e0 100644 --- a/packages/nest/src/common.ts +++ b/packages/nest/src/common.ts @@ -1,16 +1,16 @@ import * as Path from './path.js' +/** + * + * @param path + */ export function addOrIncreaseNameNumber( - path: Path.Directory> -): Path.Directory> -export function addOrIncreaseNameNumber( - path: Path.File> -): Path.File> -export function addOrIncreaseNameNumber( - path: Path.Distinctive> -): Path.Distinctive> { - const regex = Path.isFile(path) ? /( \((\d+)\))?(\.[^$]+)?$/ : /( \((\d+)\))$/ + path: Path.PartitionedNonEmpty +): Path.PartitionedNonEmpty { const terminus = Path.terminus(path) + const regex = terminus.includes('.') + ? /( \((\d+)\))?(\.[^$]+)?$/ + : /( \((\d+)\))$/ const suffixMatches = terminus.match(regex) return Path.replaceTerminus( @@ -26,10 +26,14 @@ export function addOrIncreaseNameNumber( ) } +/** + * + * @param path + */ export function pathSegmentsWithoutPartition( - path: Path.Distinctive> + path: Path.Partitioned ): Path.Segments { - return Path.unwrap(Path.removePartition(path)) + return Path.removePartition(path) } /** diff --git a/packages/nest/src/data/sample.ts b/packages/nest/src/data/sample.ts index 8e56386..25dc105 100644 --- a/packages/nest/src/data/sample.ts +++ b/packages/nest/src/data/sample.ts @@ -1,19 +1,20 @@ -import * as Path from '../path.js' import type { FileSystem } from '../class.js' /** * Adds some sample to the file system. + * + * @param fs */ export async function addSampleData(fs: FileSystem): Promise { - await fs.mkdir(Path.directory('private', 'Apps')) - await fs.mkdir(Path.directory('private', 'Audio')) - await fs.mkdir(Path.directory('private', 'Documents')) - await fs.mkdir(Path.directory('private', 'Photos')) - await fs.mkdir(Path.directory('private', 'Video')) + await fs.mkdir(['private', 'Apps']) + await fs.mkdir(['private', 'Audio']) + await fs.mkdir(['private', 'Documents']) + await fs.mkdir(['private', 'Photos']) + await fs.mkdir(['private', 'Video']) // Files await fs.write( - Path.file('private', 'Welcome.txt'), + ['private', 'Welcome.txt'], 'utf8', 'Welcome to your personal transportable encrypted file system 👋' ) diff --git a/packages/nest/src/errors.ts b/packages/nest/src/errors.ts index f2281da..7da9929 100644 --- a/packages/nest/src/errors.ts +++ b/packages/nest/src/errors.ts @@ -1,9 +1,11 @@ import * as Path from './path.js' -export function throwNoAccess( - path: Path.DistinctivePath, - accessType?: string -): never { +/** + * + * @param path + * @param accessType + */ +export function throwNoAccess(path: Path.Segments, accessType?: string): never { throw new Error( `Expected to have ${ typeof accessType === 'string' ? accessType + ' ' : '' @@ -11,9 +13,11 @@ export function throwNoAccess( ) } -export function throwInvalidPartition( - path: Path.Distinctive -): never { +/** + * + * @param path + */ +export function throwInvalidPartition(path: Path.Segments): never { throw new Error( `Expected either a public or private path, got '${Path.toPosix(path)}'` ) diff --git a/packages/nest/src/exchange-key.ts b/packages/nest/src/exchange-key.ts new file mode 100644 index 0000000..9308348 --- /dev/null +++ b/packages/nest/src/exchange-key.ts @@ -0,0 +1,88 @@ +import * as Uint8Arr from 'uint8arrays' +import { webcrypto } from './crypto.js' + +export class ExchangeKey { + key: CryptoKey + + constructor(key: CryptoKey) { + this.key = key + } + + // CONSTRUCTORS + + static async fromModulus(modulus: Uint8Array): Promise { + const keyData = { + kty: 'RSA', + n: Uint8Arr.toString(modulus, 'base64url'), + e: Uint8Arr.toString(new Uint8Array([0x01, 0x00, 0x01]), 'base64url'), + alg: 'RSA-OAEP-256', + ext: true, + } + + const key = await webcrypto.subtle.importKey( + 'jwk', + keyData, + { + name: 'RSA-OAEP', + hash: { name: 'SHA-256' }, + }, + false, + ['encrypt'] + ) + + return new ExchangeKey(key) + } + + // RELATED + + static async decrypt( + data: Uint8Array, + privateKey: CryptoKey + ): Promise { + const buffer = await webcrypto.subtle.decrypt( + { + name: 'RSA-OAEP', + }, + privateKey, + data + ) + + return new Uint8Array(buffer) + } + + static async generate(): Promise { + return await webcrypto.subtle.generateKey( + { + name: 'RSA-OAEP', + modulusLength: 2048, + publicExponent: new Uint8Array([0x01, 0x00, 0x01]), + hash: { name: 'SHA-256' }, + }, + true, + ['decrypt'] + ) + } + + // INSTANCE METHODS + + async encrypt(data: Uint8Array): Promise { + const encryptedData = await webcrypto.subtle.encrypt( + { + name: 'RSA-OAEP', + }, + this.key, + data + ) + + return new Uint8Array(encryptedData) + } + + async publicKeyModulus(): Promise { + const key = await webcrypto.subtle.exportKey('jwk', this.key) + if (key.n === undefined) throw new Error('key.n is undefined') + return Uint8Arr.fromString(key.n, 'base64url') + } +} + +// @ts-expect-error Required by the `wnfs` package +globalThis.ExchangeKey = ExchangeKey diff --git a/packages/nest/src/index.ts b/packages/nest/src/index.ts index 301bdf0..f1f480c 100644 --- a/packages/nest/src/index.ts +++ b/packages/nest/src/index.ts @@ -4,6 +4,7 @@ export * from './app-info.js' export * from './class.js' export * from './errors.js' export * from './events.js' +export * from './exchange-key.js' export * from './root-tree.js' export * from './root-tree/basic.js' export * from './transaction.js' diff --git a/packages/nest/src/mounts.ts b/packages/nest/src/mounts.ts index 4a7abe0..1606f10 100644 --- a/packages/nest/src/mounts.ts +++ b/packages/nest/src/mounts.ts @@ -19,21 +19,18 @@ import { throwInvalidPartition, throwNoAccess } from './errors.js' * * Starts from the path `/` and works up to given path, * which could be a file or directory path. + * + * @param path + * @param privateNodes */ export function findPrivateNode( - path: Path.Distinctive>, + path: Partitioned, privateNodes: MountedPrivateNodes ): PrivateNodeQueryResult { - const pathKind = Path.kind(path) - const pathWithoutPartition = Path.removePartition(path) - const pathSegments = Path.unwrap(pathWithoutPartition) + const pathSegments = Path.removePartition(path) for (let i = 0; i <= pathSegments.length; i++) { - const path = Path.fromKind( - i === pathSegments.length ? pathKind : Path.Kind.Directory, - ...pathSegments.slice(0, i) - ) - + const path = pathSegments.slice(0, i) const result: MountedPrivateNode | undefined = privateNodes[Path.toPosix(path, { absolute: true })] @@ -49,25 +46,28 @@ export function findPrivateNode( } export function partition

( - path: Path.Distinctive> + path: PartitionedNonEmpty

): PartitionDiscoveryNonEmpty

export function partition

( - path: Path.Distinctive> + path: Partitioned

): PartitionDiscovery

-export function partition(path: Path.Distinctive>): { +/** + * + * @param path + */ +export function partition(path: Partitioned): { name: 'public' | 'private' - path: Path.Distinctive> + path: Partitioned segments: Path.Segments } { - const unwrapped = Path.unwrap(path) - const rest = unwrapped.slice(1) + const rest = path.slice(1) - switch (unwrapped[0]) { + switch (path[0]) { case 'public': { - return { name: 'public', path: path, segments: rest } + return { name: 'public', path, segments: rest } } case 'private': { - return { name: 'private', path: path, segments: rest } + return { name: 'private', path, segments: rest } } default: { throwInvalidPartition(path) diff --git a/packages/nest/src/mutations.ts b/packages/nest/src/mutations.ts index 448e17b..7eff837 100644 --- a/packages/nest/src/mutations.ts +++ b/packages/nest/src/mutations.ts @@ -20,6 +20,7 @@ import { searchLatest } from './common.js' export const TYPES: Record = { ADDED_OR_UPDATED: 'added-or-updated', REMOVED: 'removed', + SHARED: 'shared', } // PUBLIC diff --git a/packages/nest/src/path.ts b/packages/nest/src/path.ts index e3dfa81..c4a72ba 100644 --- a/packages/nest/src/path.ts +++ b/packages/nest/src/path.ts @@ -1,11 +1,11 @@ // 🧩 -import type { AppInfo } from './app-info.js' - export enum RootBranch { + DID = 'did', Exchange = 'exchange', Private = 'private', Public = 'public', + Tally = 'tally', Unix = 'unix', Version = 'version', } @@ -36,479 +36,233 @@ export type Public = 'public' | RootBranch.Public */ export type Partition = Private | Public +export function priv(...args: SegmentsNonEmpty): PartitionedNonEmpty +export function priv(...args: Segments): Partitioned /** - * A directory path. - */ -export interface DirectoryPath

{ - directory: P -} - -/** - * A file path. - */ -export interface FilePath

{ - file: P -} - -/** - * A file or directory path. - */ -export type DistinctivePath

= DirectoryPath

| FilePath

- -/** - * Alias for `DirectoryPath` - */ -export type Directory

= DirectoryPath

- -/** - * Alias for `FilePath` - */ -export type File

= FilePath

- -/** - * Alias for `DistinctivePath` - */ -export type Distinctive

= DistinctivePath

- -// CREATION - -/** - * Utility function to create a `DirectoryPath` - * - * @group 🪺 :: START HERE - */ -export function directory

( - ...args: PartitionedNonEmpty

-): DirectoryPath> -export function directory

( - ...args: Partitioned

-): DirectoryPath> -export function directory( - ...args: SegmentsNonEmpty -): DirectoryPath -export function directory(...args: Segments): DirectoryPath -export function directory(...args: Segments): DirectoryPath { - if (args.some((p) => p.includes('/'))) { - throw new Error('Forward slashes `/` are not allowed') - } - return { directory: args } -} - -/** - * Utility function to create a `FilePath` - * - * @group 🪺 :: START HERE - */ -export function file

( - ...args: PartitionedNonEmpty

-): FilePath> -export function file(...args: SegmentsNonEmpty): FilePath -export function file(...args: Segments): FilePath -export function file(...args: Segments): FilePath { - if (args.some((p) => p.includes('/'))) { - throw new Error('Forward slashes `/` are not allowed') - } - return { file: args } -} - -/** - * Utility function to create a path based on the given `Kind` + * Utility function to create a private path. * + * @param args * @group 🪺 :: START HERE */ -export function fromKind

( - kind: Kind.Directory, - ...args: PartitionedNonEmpty

-): DirectoryPath> -export function fromKind

( - kind: Kind.Directory, - ...args: Partitioned

-): DirectoryPath> -export function fromKind( - kind: Kind.Directory, - ...args: SegmentsNonEmpty -): DirectoryPath -export function fromKind( - kind: Kind.Directory, - ...args: Segments -): DirectoryPath -export function fromKind

( - kind: Kind.File, - ...args: PartitionedNonEmpty

-): FilePath> -export function fromKind( - kind: Kind.File, - ...args: SegmentsNonEmpty -): FilePath -export function fromKind(kind: Kind.File, ...args: Segments): FilePath -export function fromKind

( - kind: Kind, - ...args: PartitionedNonEmpty

-): DistinctivePath> -export function fromKind

( - kind: Kind, - ...args: Partitioned

-): DistinctivePath> -export function fromKind( - kind: Kind, - ...args: SegmentsNonEmpty -): DistinctivePath -export function fromKind( - kind: Kind, - ...args: Segments -): DistinctivePath -export function fromKind( - kind: Kind, - ...args: Segments -): DistinctivePath { - return kind === Kind.Directory ? directory(...args) : file(...args) +export function priv(...args: Segments): Partitioned { + return ['private', ...args] } +export function pub(...args: SegmentsNonEmpty): PartitionedNonEmpty +export function pub(...args: Segments): Partitioned /** - * Utility function to create a root `DirectoryPath` + * Utility function to create a public path. * + * @param args * @group 🪺 :: START HERE */ -export function root(): DirectoryPath { - return { directory: [] } +export function pub(...args: Segments): Partitioned { + return ['public', ...args] } /** - * Utility function create an app data path. + * Utility function to create a root path (aka. an empty array) * * @group 🪺 :: START HERE */ -export function appData

( - partition: P, - app: AppInfo -): DirectoryPath> -export function appData

( - partition: P, - app: AppInfo, - suffix: FilePath -): FilePath> -export function appData

( - partition: P, - app: AppInfo, - suffix: DirectoryPath -): DirectoryPath> -export function appData

( - partition: P, - app: AppInfo, - suffix: DistinctivePath -): DistinctivePath> -export function appData

( - partition: P, - app: AppInfo, - suffix?: DistinctivePath -): DistinctivePath> { - const appDir = directory(partition, 'Apps', app.creator, app.name) - return suffix === undefined ? appDir : combine(appDir, suffix) +export function root(): [] { + return [] } // POSIX /** - * Transform a string into a `DistinctivePath`. + * Transform a POSIX string into a path. * * Directories should have the format `path/to/dir/` and * files should have the format `path/to/file`. * * Leading forward slashes are removed too, so you can pass absolute paths. * + * @param path * @group POSIX */ -export function fromPosix(path: string): DistinctivePath { +export function fromPosix(path: string): Segments { const split = path.replace(/^\/+/, '').split('/') - if (path.endsWith('/')) return { directory: split.slice(0, -1) } + if (path.endsWith('/')) return split.slice(0, -1) else if (path === '') return root() - return { file: split } + return split } /** - * Transform a `DistinctivePath` into a string. + * Transform a path into a POSIX string. * * Directories will have the format `path/to/dir/` and * files will have the format `path/to/file`. * + * @param path + * @param options + * @param options.absolute + * @param options.directory * @group POSIX */ export function toPosix( - path: DistinctivePath, - options?: { absolute?: boolean } + path: Segments, + options?: { absolute?: boolean; directory?: boolean } ): string { const prefix = options?.absolute === true ? '/' : '' - const joinedPath = unwrap(path).join('/') - if (isDirectory(path)) + const joinedPath = path.join('/') + if (options?.directory === true) return prefix + joinedPath + (joinedPath.length > 0 ? '/' : '') return prefix + joinedPath } // 🛠️ -/** - * Combine two `DistinctivePath`s. - */ -export function combine

( - a: DirectoryPath>, - b: FilePath -): FilePath> -export function combine

( - a: DirectoryPath>, - b: FilePath -): FilePath> -export function combine

( - a: DirectoryPath>, - b: FilePath -): FilePath> -export function combine( - a: DirectoryPath, - b: FilePath -): FilePath -export function combine( - a: DirectoryPath, - b: FilePath -): FilePath -export function combine

( - a: DirectoryPath>, - b: DirectoryPath -): DirectoryPath> -export function combine

( - a: DirectoryPath>, - b: DirectoryPath -): DirectoryPath> -export function combine

( - a: DirectoryPath>, - b: DirectoryPath -): DirectoryPath> -export function combine( - a: DirectoryPath, - b: DirectoryPath -): DirectoryPath -export function combine( - a: DirectoryPath, - b: DirectoryPath -): DirectoryPath export function combine

( - a: DirectoryPath>, - b: DistinctivePath -): DistinctivePath> + a: PartitionedNonEmpty

, + b: Segments +): PartitionedNonEmpty

export function combine

( - a: DirectoryPath>, - b: DistinctivePath -): DistinctivePath> + a: Partitioned

, + b: SegmentsNonEmpty +): PartitionedNonEmpty

export function combine

( - a: DirectoryPath>, - b: DistinctivePath -): DistinctivePath> -export function combine( - a: DirectoryPath, - b: DistinctivePath -): DistinctivePath -export function combine( - a: DirectoryPath, - b: DistinctivePath -): DistinctivePath -export function combine( - a: DirectoryPath, - b: DistinctivePath -): DistinctivePath { - return map((p) => [...unwrap(a), ...p], b) -} - -/** - * Is this `DistinctivePath` a directory? - */ -export function isDirectory

( - path: DistinctivePath

-): path is DirectoryPath

{ - return 'directory' in path -} - + a: Partitioned

, + b: Segments +): Partitioned

+export function combine(a: Segments, b: SegmentsNonEmpty): SegmentsNonEmpty +export function combine(a: Segments, b: Segments): Segments /** - * Is this `DistinctivePath` a file? + * Combine two `DistinctivePath`s. + * + * @param a + * @param b */ -export function isFile

(path: DistinctivePath

): path is FilePath

{ - return 'file' in path +export function combine(a: Segments, b: Segments): Segments { + return [...a, ...b] } /** - * Is this `DistinctivePath` on the given `RootBranch`? + * Is this path on the given `RootBranch`? + * + * @param rootBranch + * @param path */ export function isOnRootBranch( rootBranch: RootBranch, - path: DistinctivePath + path: Segments ): boolean { - return unwrap(path)[0] === rootBranch + return path[0] === rootBranch } /** - * Is this `DistinctivePath` of the given `Partition`? + * Is this path of the given `Partition`? + * + * @param partition + * @param path */ -export function isPartition( - partition: Partition, - path: DistinctivePath -): boolean { - return unwrap(path)[0] === partition +export function isPartition(partition: Partition, path: Segments): boolean { + return path[0] === partition } /** - * Is this a partitioned `DistinctivePath`? + * Is this a partitioned path? + * + * @param path */ export function isPartitioned

( - path: DistinctivePath -): path is DistinctivePath> { - const soCalledPartition = unwrap(path)[0] + path: Segments +): path is Partitioned

{ + const soCalledPartition = path[0] return [RootBranch.Private, RootBranch.Public, 'private', 'public'].includes( soCalledPartition ) } /** - * Is this partitioned `DistinctivePath` non-empty? + * Is this partitioned path non-empty? + * + * @param path */ export function isPartitionedNonEmpty

( - path: DistinctivePath -): path is DistinctivePath> { - return isPartitioned(path) && length(path) > 1 -} - -/** - * Is this `DirectoryPath` a root directory? - */ -export function isRootDirectory(path: DirectoryPath): boolean { - return path.directory.length === 0 + path: Segments +): path is PartitionedNonEmpty

{ + return isPartitioned(path) && path.length > 1 } /** - * Check if two `DistinctivePath` have the same `Partition`. - */ -export function isSamePartition( - a: DistinctivePath, - b: DistinctivePath -): boolean { - return unwrap(a)[0] === unwrap(b)[0] -} - -/** - * Check if two `DistinctivePath` are of the same kind. - */ -export function isSameKind( - a: DistinctivePath, - b: DistinctivePath -): boolean { - if (isDirectory(a) && isDirectory(b)) return true - else if (isFile(a) && isFile(b)) return true - else return false -} - -/** - * What `Kind` of path are we dealing with? - */ -export function kind

(path: DistinctivePath

): Kind { - if (isDirectory(path)) return Kind.Directory - return Kind.File -} - -/** - * What's the length of a path? + * Is this path a root path? + * + * @param path */ -export function length(path: DistinctivePath): number { - return unwrap(path).length +export function isRoot(path: Segments): boolean { + return path.length === 0 } /** - * Map a `DistinctivePath`. + * Check if two paths have the same `Partition`. + * + * @param a + * @param b */ -export function map( - fn: (p: A) => B, - path: DistinctivePath -): DistinctivePath { - if (isDirectory(path)) return { directory: fn(path.directory) } - else if (isFile(path)) return { file: fn(path.file) } - return path +export function isSamePartition(a: Segments, b: Segments): boolean { + return a[0] === b[0] } -/** - * Get the parent directory of a `DistinctivePath`. - */ -export function parent( - path: DistinctivePath<[Partition, Segment, Segment, ...Segments]> -): DirectoryPath> -export function parent( - path: DistinctivePath<[Segment, Segment, Segment, ...Segments]> -): DirectoryPath export function parent( - path: DistinctivePath> -): DirectoryPath> + path: [Partition, Segment, Segment, ...Segments] +): PartitionedNonEmpty export function parent( - path: DistinctivePath<[Partition, Segment]> -): DirectoryPath> + path: [Segment, Segment, Segment, ...Segments] +): SegmentsNonEmpty export function parent( - path: DistinctivePath> -): DirectoryPath -export function parent( - path: DistinctivePath -): DirectoryPath -export function parent(path: DistinctivePath<[Segment]>): DirectoryPath<[]> -export function parent(path: DistinctivePath<[]>): undefined -export function parent( - path: DistinctivePath -): DirectoryPath | undefined -export function parent( - path: DistinctivePath -): DirectoryPath | undefined { - return isDirectory(path) && isRootDirectory(path) - ? undefined - : directory(...unwrap(path).slice(0, -1)) + path: PartitionedNonEmpty +): Partitioned +export function parent(path: [Partition, Segment]): Partitioned +export function parent(path: Partitioned): Segments +export function parent(path: SegmentsNonEmpty): Segments +export function parent(path: [Segment]): [] +export function parent(path: []): undefined +export function parent(path: Segments): Segments | undefined +/** + * Get the parent directory of a path. + * + * @param path + */ +export function parent(path: Segments): Segments | undefined { + return path.slice(0, -1) } /** - * Remove the `Partition` of a `DistinctivePath` (ie. the top-level directory) + * Remove the `Partition` of a path (ie. the top-level directory) + * + * @param path */ -export function removePartition( - path: DistinctivePath -): DistinctivePath { - return map((p) => (isDirectory(path) || p.length > 1 ? p.slice(1) : p), path) +export function removePartition(path: Segments): Segments { + return isPartitioned(path) ? path.slice(1) : path } export function replaceTerminus( - path: FilePath>, - terminus: string -): FilePath> -export function replaceTerminus( - path: DirectoryPath>, - terminus: string -): DirectoryPath> -export function replaceTerminus( - path: DistinctivePath>, - terminus: string -): DistinctivePath> -export function replaceTerminus( - path: FilePath, + path: PartitionedNonEmpty, terminus: string -): FilePath -export function replaceTerminus( - path: DirectoryPath, - terminus: string -): DirectoryPath -export function replaceTerminus( - path: DistinctivePath, - terminus: string -): DistinctivePath +): PartitionedNonEmpty +/** + * + * @param path + * @param terminus + */ export function replaceTerminus( - path: DistinctivePath | DistinctivePath, + path: SegmentsNonEmpty, terminus: string -): DistinctivePath { - return combine(parent(path), fromKind(kind(path), terminus)) +): SegmentsNonEmpty { + return combine(parent(path), [terminus]) } +/** + * + * @param path + */ export function rootBranch( - path: DistinctivePath + path: Segments ): { branch: RootBranch; rest: Segments } | undefined { - const unwrapped = unwrap(path) - const firstSegment = unwrapped[0] - const rest = unwrapped.slice(1) + const firstSegment = path[0] + const rest = path.slice(1) switch (firstSegment) { case RootBranch.Exchange: { @@ -537,72 +291,47 @@ export function rootBranch( } } +export function terminus(path: PartitionedNonEmpty): string +export function terminus(path: Partitioned): string +export function terminus(path: SegmentsNonEmpty): string +export function terminus(path: Segments): string | undefined /** * Get the last part of the path. + * + * @param path */ -export function terminus( - path: DistinctivePath> -): string -export function terminus(path: DistinctivePath>): string -export function terminus(path: DistinctivePath): string -export function terminus(path: DistinctivePath): string | undefined -export function terminus(path: DistinctivePath): string | undefined { - const u = unwrap(path) - if (u.length === 0) return undefined - return u.at(-1) -} - -/** - * Unwrap a `DistinctivePath`. - */ -export function unwrap

(path: DistinctivePath

): P { - if (isDirectory(path)) { - return path.directory - } else if (isFile(path)) { - return path.file - } - - throw new Error('Path is neither a directory or a file') +export function terminus(path: Segments): string | undefined { + if (path.length === 0) return undefined + return path.at(-1) } -/** - * Utility function to prefix a path with a `Partition`. - */ -export function withPartition

( - partition: P, - path: DirectoryPath -): DirectoryPath> -export function withPartition

( - partition: P, - path: DirectoryPath -): DirectoryPath> -export function withPartition

( - partition: P, - path: FilePath -): FilePath> -export function withPartition

( - partition: P, - path: FilePath -): FilePath> export function withPartition

( partition: P, - path: DistinctivePath -): DistinctivePath> + path: SegmentsNonEmpty +): PartitionedNonEmpty

export function withPartition

( partition: P, - path: DistinctivePath -): DistinctivePath> + path: Segments +): Partitioned

+/** + * Utility function to prefix a path with a `Partition`. + * + * @param partition + * @param path + */ export function withPartition

( partition: P, - path: DistinctivePath -): DistinctivePath> { - return combine(directory(partition), path) + path: Segments +): Partitioned

{ + return [partition, ...path] } // 🔬 /** * Render a raw `Path` to a string for logging purposes. + * + * @param path */ export function log(path: Segments): string { return `[ ${path.join(', ')} ]` diff --git a/packages/nest/src/queries.ts b/packages/nest/src/queries.ts index 4d1b069..2844819 100644 --- a/packages/nest/src/queries.ts +++ b/packages/nest/src/queries.ts @@ -37,14 +37,20 @@ export interface PublicParams { export type Public = (params: PublicParams) => Promise export type PublicContext = Omit +/** + * + * @param path + * @param qry + * @param context + */ export async function publicQuery( - path: Path.Distinctive>, + path: Partitioned, qry: Public, context: PublicContext ): Promise { return await qry({ blockstore: context.blockstore, - pathSegments: Path.unwrap(Path.removePartition(path)), + pathSegments: Path.removePartition(path), rootTree: context.rootTree, }) } @@ -59,6 +65,21 @@ export const publicExists = () => { } } +export const publicItemKind = () => { + return async (params: PublicParams): Promise => { + const result = await params.rootTree + .publicRoot() + .getNode(params.pathSegments, Store.wnfs(params.blockstore)) + + if (result !== null && result !== undefined) + return (result as PublicNode).isFile() + ? Path.Kind.File + : Path.Kind.Directory + + return undefined + } +} + export const publicListDirectory = () => { return async (params: PublicParams): Promise => { return await params.rootTree @@ -92,9 +113,9 @@ export const publicListDirectoryWithKind = () => { return { ...item, kind, - path: Path.combine( - Path.directory('public', ...params.pathSegments), - Path.fromKind(kind, item.name) + path: Path.withPartition( + 'public', + Path.combine(params.pathSegments, [item.name]) ), } }) @@ -166,8 +187,14 @@ export type PrivateParams = { export type Private = (params: PrivateParams) => Promise export type PrivateContext = Omit +/** + * + * @param path + * @param qry + * @param context + */ export async function privateQuery( - path: Path.Distinctive>, + path: Partitioned, qry: Private, context: PrivateContext ): Promise { @@ -200,6 +227,28 @@ export const privateExists = () => { } } +export const privateItemKind = () => { + return async (params: PrivateParams): Promise => { + if (params.node.isFile()) return Path.Kind.File + + const result = await params.node + .asDir() + .getNode( + params.remainder, + searchLatest(), + params.rootTree.privateForest(), + Store.wnfs(params.blockstore) + ) + + if (result !== null && result !== undefined) + return (result as PrivateNode).isFile() + ? Path.Kind.File + : Path.Kind.Directory + + return undefined + } +} + export const privateListDirectory = () => { return async (params: PrivateParams): Promise => { if (params.node.isFile()) throw new Error('Cannot list a file') @@ -240,14 +289,10 @@ export const privateListDirectoryWithKind = () => { ) .then((a) => a.result) - const parentPath = Path.combine( - Path.directory('private', ...Path.unwrap(params.path)), - Path.directory(...params.remainder) - ) - - if (!Path.isDirectory(parentPath)) { - throw new Error("Didn't expect a file path") - } + const parentPath = Path.withPartition('private', [ + ...params.path, + ...params.remainder, + ]) const promises = items.map( async (item: DirectoryItem): Promise => { @@ -263,7 +308,7 @@ export const privateListDirectoryWithKind = () => { return { ...item, kind, - path: Path.combine(parentPath, Path.fromKind(kind, item.name)), + path: Path.combine(parentPath, [item.name]), } } ) @@ -327,7 +372,6 @@ export const privateReadFromAccessKey = ( if (node.isFile() === true) { const file: PrivateFile = node.asFile() - // TODO: Respect the offset and length options when available in rs-wnfs return await file.readAt( options?.offset ?? 0, options?.length ?? undefined, diff --git a/packages/nest/src/references.ts b/packages/nest/src/references.ts index ed3c436..c497281 100644 --- a/packages/nest/src/references.ts +++ b/packages/nest/src/references.ts @@ -9,10 +9,16 @@ import type { RootTree } from './root-tree.js' import * as Store from './store.js' import { pathSegmentsWithoutPartition } from './common.js' +/** + * + * @param blockstore + * @param rootTree + * @param path + */ export async function contentCID( blockstore: Blockstore, rootTree: RootTree, - path: Path.File> + path: Path.Partitioned ): Promise { const wnfsBlockstore = Store.wnfs(blockstore) const result = await rootTree @@ -30,10 +36,16 @@ export async function contentCID( : undefined } +/** + * + * @param blockstore + * @param rootTree + * @param path + */ export async function capsuleCID( blockstore: Blockstore, rootTree: RootTree, - path: Path.Distinctive> + path: Path.Partitioned ): Promise { const wnfsBlockstore = Store.wnfs(blockstore) const result = await rootTree diff --git a/packages/nest/src/root-tree.ts b/packages/nest/src/root-tree.ts index 84def0e..a9b9304 100644 --- a/packages/nest/src/root-tree.ts +++ b/packages/nest/src/root-tree.ts @@ -8,17 +8,27 @@ import type { Modification } from './types.js' * The tree that ties different file systems together. */ export abstract class RootTree { + abstract did(): string | undefined + abstract replaceDID(did: string): Promise + + abstract exchangeRoot(): PublicDirectory + abstract replaceExchangeRoot(dir: PublicDirectory): Promise + abstract privateForest(): PrivateForest abstract replacePrivateForest( forest: PrivateForest, modifications: Modification[] ): Promise + abstract publicRoot(): PublicDirectory abstract replacePublicRoot( dir: PublicDirectory, modifications: Modification[] ): Promise + abstract shareCounter(): number + abstract increaseShareCounter(): Promise + abstract clone(): RootTree abstract store(): Promise diff --git a/packages/nest/src/root-tree/basic.ts b/packages/nest/src/root-tree/basic.ts index 78eefe6..93eb2df 100644 --- a/packages/nest/src/root-tree/basic.ts +++ b/packages/nest/src/root-tree/basic.ts @@ -2,11 +2,12 @@ import type { PBLink, PBNode } from '@ipld/dag-pb' import type { Blockstore } from 'interface-blockstore' import * as DagPB from '@ipld/dag-pb' +import * as JsonCodec from 'multiformats/codecs/json' import * as Raw from 'multiformats/codecs/raw' import * as Uint8Arrays from 'uint8arrays' import { CID } from 'multiformats/cid' -import { PrivateForest, PublicDirectory } from 'wnfs' +import { PrivateForest, PublicDirectory, type PublicNode } from 'wnfs' import * as Path from '../path.js' import * as References from '../references.js' @@ -25,37 +26,47 @@ import { webcrypto } from '../crypto.js' export class BasicRootTree implements RootTree { readonly #blockstore: Blockstore + readonly #did: string | undefined readonly #exchangeRoot: PublicDirectory readonly #privateForest: PrivateForest readonly #publicRoot: PublicDirectory + readonly #shareCounter: number readonly #unix: PBNode readonly #version: string constructor({ blockstore, + did, exchangeRoot, publicRoot, privateForest, + shareCounter, unix, version, }: { blockstore: Blockstore + did: string | undefined exchangeRoot: PublicDirectory privateForest: PrivateForest publicRoot: PublicDirectory + shareCounter: number unix: PBNode version: string }) { this.#blockstore = blockstore + this.#did = did this.#exchangeRoot = exchangeRoot this.#privateForest = privateForest this.#publicRoot = publicRoot + this.#shareCounter = shareCounter this.#unix = unix this.#version = version } /** * Create a new root tree. + * + * @param blockstore */ static async create(blockstore: Blockstore): Promise { const currentTime = new Date() @@ -63,9 +74,11 @@ export class BasicRootTree implements RootTree { return new BasicRootTree({ blockstore, + did: undefined, exchangeRoot: new PublicDirectory(currentTime), publicRoot: new PublicDirectory(currentTime), privateForest: await createPrivateForest(), + shareCounter: 0, unix: Unix.createDirectory(currentTime), version: Version.latest, }) @@ -73,6 +86,9 @@ export class BasicRootTree implements RootTree { /** * Load an existing root tree. + * + * @param blockstore + * @param cid */ static async fromCID( blockstore: Blockstore, @@ -85,21 +101,34 @@ export class BasicRootTree implements RootTree { const links = await linksFromCID(cid, blockstore) // Retrieve all pieces + /** + * + * @param name + * @param present + * @param missing + */ async function handleLink( name: string, present: (cid: CID) => Promise, missing: () => T | Promise ): Promise { if (links[name] === undefined) { - console.warn( - `Missing '${name}' link in the root tree from '${cid.toString()}'. Creating a new link.` - ) return await missing() } return await present(links[name]) } + const did = await handleLink( + RootBranch.DID, + async (cid) => { + const block = await blockstore.get(cid) + return new TextDecoder().decode(Raw.decode(block)) + }, + // eslint-disable-next-line unicorn/no-useless-undefined + () => undefined + ) + const exchangeRoot = await handleLink( RootBranch.Exchange, async (cid) => await PublicDirectory.load(cid.bytes, wnfsStore), @@ -124,10 +153,20 @@ export class BasicRootTree implements RootTree { () => Unix.createDirectory(currentTime) ) + const shareCounter = await handleLink( + RootBranch.Tally, + async (cid) => { + const block = await blockstore.get(cid) + return await JsonCodec.decode(block) + }, + () => 0 + ) + const version = await handleLink( RootBranch.Version, async (cid) => { - return new TextDecoder().decode(Raw.decode(await blockstore.get(cid))) + const block = await blockstore.get(cid) + return new TextDecoder().decode(Raw.decode(block)) }, () => Version.latest ) @@ -136,14 +175,52 @@ export class BasicRootTree implements RootTree { return new BasicRootTree({ blockstore, + did, exchangeRoot, publicRoot, privateForest, + shareCounter, unix, version, }) } + did(): string | undefined { + return this.#did + } + + async replaceDID(did: string): Promise { + return new BasicRootTree({ + blockstore: this.#blockstore, + + did, + exchangeRoot: this.#exchangeRoot, + publicRoot: this.#publicRoot, + privateForest: this.#privateForest, + shareCounter: this.#shareCounter, + unix: this.#unix, + version: this.#version, + }) + } + + exchangeRoot(): PublicDirectory { + return this.#exchangeRoot + } + + async replaceExchangeRoot(dir: PublicDirectory): Promise { + return new BasicRootTree({ + blockstore: this.#blockstore, + + did: this.#did, + exchangeRoot: dir, + publicRoot: this.#publicRoot, + privateForest: this.#privateForest, + shareCounter: this.#shareCounter, + unix: this.#unix, + version: this.#version, + }) + } + privateForest(): PrivateForest { return this.#privateForest } @@ -155,9 +232,11 @@ export class BasicRootTree implements RootTree { return new BasicRootTree({ blockstore: this.#blockstore, + did: this.#did, exchangeRoot: this.#exchangeRoot, publicRoot: this.#publicRoot, privateForest: forest, + shareCounter: this.#shareCounter, unix: this.#unix, version: this.#version, }) @@ -174,9 +253,11 @@ export class BasicRootTree implements RootTree { const treeWithNewPublicRoot = new BasicRootTree({ blockstore: this.#blockstore, + did: this.#did, exchangeRoot: this.#exchangeRoot, publicRoot: dir, privateForest: this.#privateForest, + shareCounter: this.#shareCounter, unix: this.#unix, version: this.#version, }) @@ -194,8 +275,14 @@ export class BasicRootTree implements RootTree { return await Unix.removeNodeFromTree(oldRoot, path, this.#blockstore) } + const node: PublicNode = await dir.getNode( + path, + Store.wnfs(this.#blockstore) + ) + const itemKind = node.isFile() ? Path.Kind.File : Path.Kind.Directory + const contentCID = - Path.isFile(mod.path) && + itemKind === Path.Kind.File && Path.isPartitionedNonEmpty(mod.path) ? await References.contentCID( this.#blockstore, @@ -205,6 +292,7 @@ export class BasicRootTree implements RootTree { : undefined return await Unix.insertNodeIntoTree( + itemKind, oldRoot, path, this.#blockstore, @@ -215,21 +303,43 @@ export class BasicRootTree implements RootTree { return new BasicRootTree({ blockstore: this.#blockstore, + did: this.#did, exchangeRoot: this.#exchangeRoot, publicRoot: dir, privateForest: this.#privateForest, + shareCounter: this.#shareCounter, unix: unixTree, version: this.#version, }) } + shareCounter(): number { + return this.#shareCounter + } + + async increaseShareCounter(): Promise { + return new BasicRootTree({ + blockstore: this.#blockstore, + + did: this.#did, + exchangeRoot: this.#exchangeRoot, + publicRoot: this.#publicRoot, + privateForest: this.#privateForest, + shareCounter: this.shareCounter() + 1, + unix: this.#unix, + version: this.#version, + }) + } + clone(): RootTree { return new BasicRootTree({ blockstore: this.#blockstore, + did: this.#did, exchangeRoot: this.#exchangeRoot, publicRoot: this.#publicRoot, privateForest: this.#privateForest, + shareCounter: this.#shareCounter, unix: this.#unix, version: this.#version, }) @@ -248,14 +358,30 @@ export class BasicRootTree implements RootTree { this.#blockstore ) + const tally = await Store.store( + JsonCodec.encode(this.#shareCounter), + JsonCodec.code, + this.#blockstore + ) + const version = await Store.store( Raw.encode(new TextEncoder().encode(this.#version)), Raw.code, this.#blockstore ) + // DID + const did = + this.#did === undefined + ? undefined + : await Store.store( + Raw.encode(new TextEncoder().encode(this.#did)), + Raw.code, + this.#blockstore + ) + // Store root tree - const links = [ + let links = [ { Name: RootBranch.Exchange, Hash: CID.decode(exchangeRoot as Uint8Array), @@ -268,6 +394,10 @@ export class BasicRootTree implements RootTree { Name: RootBranch.Public, Hash: CID.decode(publicRoot as Uint8Array), }, + { + Name: RootBranch.Tally, + Hash: tally, + }, { Name: RootBranch.Unix, Hash: unixTree, @@ -278,6 +408,15 @@ export class BasicRootTree implements RootTree { }, ] + if (did !== undefined) + links = [ + ...links, + { + Name: RootBranch.DID, + Hash: did, + }, + ] + const node = DagPB.createNode(new Uint8Array([8, 1]), links) // Fin @@ -317,6 +456,9 @@ async function createPrivateForest(): Promise { /** * Retrieve the links of a root tree. + * + * @param cid + * @param blockstore */ export async function linksFromCID( cid: CID, diff --git a/packages/nest/src/share.ts b/packages/nest/src/share.ts new file mode 100644 index 0000000..8286e39 --- /dev/null +++ b/packages/nest/src/share.ts @@ -0,0 +1,148 @@ +import type { Blockstore } from 'interface-blockstore' +import type { PrivateNode } from 'wnfs' + +import * as Queries from './queries.js' +import * as Path from './path.js' + +import type { Segments } from './path.js' +import type { Rng } from './rng.js' +import type { RootTree } from './root-tree.js' + +import type { + AnySupportedDataType, + DataForType, + DataType, + DirectoryItem, + DirectoryItemWithKind, +} from './types.js' + +import type { MountedPrivateNodes } from './types/internal.js' + +import { dataFromBytes } from './data.js' + +// CLASS + +export class Share { + readonly id: string + + readonly #blockstore: Blockstore + readonly #privateNodes: MountedPrivateNodes + readonly #rootTree: RootTree + readonly #rng: Rng + + /** + * @param id + * @param blockstore + * @param privateNodes + * @param rng + * @param rootTree + * @internal + */ + constructor( + id: string, + blockstore: Blockstore, + privateNodes: MountedPrivateNodes, + rng: Rng, + rootTree: RootTree + ) { + this.id = id + this.#blockstore = blockstore + this.#privateNodes = privateNodes + this.#rng = rng + this.#rootTree = rootTree + } + + // EXPORT + + export(): PrivateNode { + const node = this.#privateNodes['/'] + if (node === undefined) + throw new Error('Expected a node to be mounted at root') + return node.node + } + + // QUERIES + + /** + * @param path + * @group Querying + */ + async exists(path?: Segments): Promise { + return await this.#query(path ?? Path.root(), Queries.privateExists()) + } + + /** @group Querying */ + async listDirectory( + path: Segments, + listOptions: { withItemKind: true } + ): Promise + async listDirectory( + path: Segments, + listOptions: { withItemKind: false } + ): Promise + async listDirectory(): Promise + async listDirectory(path: Segments): Promise + async listDirectory( + path?: Segments, + listOptions?: { withItemKind: boolean } + ): Promise + async listDirectory( + path?: Segments, + listOptions?: { withItemKind: boolean } + ): Promise { + return await this.#query( + path ?? Path.root(), + listOptions?.withItemKind === true + ? Queries.privateListDirectoryWithKind() + : Queries.privateListDirectory() + ) + } + + /** @group Querying */ + ls = this.listDirectory // eslint-disable-line @typescript-eslint/unbound-method + + /** @group Querying */ + async read( + dataType: D, + path?: Segments, + options?: { offset?: number; length?: number } + ): Promise> + async read( + dataType: DataType, + path?: Segments, + options?: { offset?: number; length?: number } + ): Promise> { + const bytes = await this.#query( + path ?? Path.root(), + Queries.privateRead(options) + ) + return dataFromBytes(dataType, bytes) + } + + /** + * @param path + * @group Querying + */ + async size(path?: Segments): Promise { + return await this.#query(path ?? Path.root(), Queries.privateSize()) + } + + // ㊙️ + + async #query(path: Segments, query: Queries.Private): Promise { + return await Queries.privateQuery( + Path.withPartition('private', path), + query, + this.#privateContext() + ) + } + + #privateContext(): Queries.PrivateContext { + return { + blockstore: this.#blockstore, + privateNodes: this.#privateNodes, + rng: this.#rng, + rootTree: this.#rootTree, + } + } +} diff --git a/packages/nest/src/sharing.ts b/packages/nest/src/sharing.ts new file mode 100644 index 0000000..985af41 --- /dev/null +++ b/packages/nest/src/sharing.ts @@ -0,0 +1,112 @@ +import type { PrivateNode } from 'wnfs' +import type { Blockstore } from 'interface-blockstore' +import type { CID } from 'multiformats/cid' + +import { + Name, + NameAccumulator, + createShareName, + findLatestShareCounter, + receiveShare, +} from 'wnfs' + +import type { RootTree } from './root-tree.js' + +import * as Store from './store.js' + +import { BasicRootTree } from './root-tree/basic.js' +import { ExchangeKey } from './exchange-key.js' + +/** + * + * @param sharerDataRoot + * @param exchangeKeyPair + * @param exchangeKeyPair.publicKey + * @param exchangeKeyPair.privateKey + * @param opts + * @param opts.shareId + * @param opts.sharerBlockstore + * @param opts.sharerRootTreeClass + */ +export async function loadShare( + sharerDataRoot: CID, + exchangeKeyPair: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey + }, + opts: { + shareId?: string + sharerBlockstore: Blockstore + sharerRootTreeClass?: typeof RootTree + } +): Promise<{ + shareId: string + sharedNode: PrivateNode + sharerRootTree: RootTree +}> { + const publicKeyResult = + exchangeKeyPair.publicKey instanceof CryptoKey + ? await new ExchangeKey(exchangeKeyPair.publicKey).publicKeyModulus() + : exchangeKeyPair.publicKey + + const sharerBlockstore = opts.sharerBlockstore + const sharerRootTreeClass = opts.sharerRootTreeClass ?? BasicRootTree + const sharerRootTree = await sharerRootTreeClass.fromCID( + sharerBlockstore, + sharerDataRoot + ) + + const sharerForest = sharerRootTree.privateForest() + const sharerCounter = sharerRootTree.shareCounter() + const sharerIdentifier = sharerRootTree.did() + if (sharerIdentifier === undefined) + throw new Error("The sharer's file system is missing an identifier") + + // Find the share number + const shareNumber: undefined | number | bigint = + opts.shareId === undefined + ? await findLatestShareCounter( + 0, + sharerCounter < 1 ? 1 : sharerCounter, + publicKeyResult, + sharerIdentifier, + sharerForest, + Store.wnfs(sharerBlockstore) + ) + : Number.parseInt(opts.shareId) + + if (shareNumber === undefined) + throw new Error('Failed to determine share number') + + // Determine share name + const shareLabel = createShareName( + Number(shareNumber), + sharerIdentifier, + publicKeyResult, + sharerForest + ) + + const shareLabelSerialized = shareLabel + .toNameAccumulator(sharerForest) + .toBytes() + const shareLabelDeserialized = new Name( + NameAccumulator.fromBytes(shareLabelSerialized) + ) + + // Load shared private node + const sharedNode: PrivateNode = await receiveShare( + shareLabelDeserialized, + { + decrypt: async (data: Uint8Array) => + await ExchangeKey.decrypt(data, exchangeKeyPair.privateKey), + }, + sharerForest, + Store.wnfs(sharerBlockstore) + ) + + return { + shareId: Number(shareNumber).toString(), + sharedNode, + sharerRootTree, + } +} diff --git a/packages/nest/src/transaction.ts b/packages/nest/src/transaction.ts index 7da8466..8213192 100644 --- a/packages/nest/src/transaction.ts +++ b/packages/nest/src/transaction.ts @@ -1,8 +1,12 @@ import type { PrivateForest, PrivateNode } from 'wnfs' import type { Blockstore } from 'interface-blockstore' +import * as Uint8Arr from 'uint8arrays' import { CID } from 'multiformats/cid' -import { AccessKey, PublicFile } from 'wnfs' +import { AccessKey, PublicFile, share } from 'wnfs' + +import type { Rng } from './rng.js' +import type { RootTree } from './root-tree.js' import * as Path from './path.js' import * as Mutations from './mutations.js' @@ -18,12 +22,13 @@ import type { Public, } from './path.js' +import { BasicRootTree } from './root-tree/basic.js' +import { ExchangeKey } from './exchange-key.js' +import { Share } from './share.js' import { addOrIncreaseNameNumber, searchLatest } from './common.js' - import { dataFromBytes, dataToBytes } from './data.js' import { partition as determinePartition, findPrivateNode } from './mounts.js' -import type { Rng } from './rng.js' -import type { RootTree } from './root-tree.js' +import { loadShare } from './sharing.js' import type { AnySupportedDataType, @@ -39,6 +44,7 @@ import type { import type { MountedPrivateNodes, PrivateNodeQueryResult, + WnfsPublicResult, } from './types/internal.js' // CLASS @@ -53,10 +59,17 @@ export class TransactionContext { readonly #modifications: Set<{ type: MutationType - path: Path.Distinctive> + path: Partitioned }> - /** @internal */ + /** + * @param blockstore + * @param onCommit + * @param privateNodes + * @param rng + * @param rootTree + * @internal + */ constructor( blockstore: Blockstore, onCommit: CommitVerifier, @@ -73,11 +86,14 @@ export class TransactionContext { this.#modifications = new Set() } - /** @internal */ + /** + * @param context + * @internal + */ static async commit(context: TransactionContext): Promise< | { modifications: Array<{ - path: Path.Distinctive> + path: Partitioned type: MutationType }> privateNodes: MountedPrivateNodes @@ -101,7 +117,7 @@ export class TransactionContext { } const maybeNode = findPrivateNode( - mod.path as Path.Distinctive>, + mod.path as Path.Partitioned, context.#privateNodes ) @@ -123,31 +139,36 @@ export class TransactionContext { // Fin return { - modifications: modifications, + modifications, privateNodes: context.#privateNodes, - rootTree: rootTree, + rootTree, } } // QUERIES - /** @group Querying */ - async contentCID( - path: Path.File> - ): Promise { + /** + * @param path + * @group Querying + */ + async contentCID(path: Partitioned): Promise { return await References.contentCID(this.#blockstore, this.#rootTree, path) } - /** @group Querying */ - async capsuleCID( - path: Path.Distinctive> - ): Promise { + /** + * @param path + * @group Querying + */ + async capsuleCID(path: Partitioned): Promise { return await References.capsuleCID(this.#blockstore, this.#rootTree, path) } - /** @group Querying */ + /** + * @param path + * @group Querying + */ async capsuleKey( - path: Path.Distinctive> + path: Partitioned ): Promise { let priv: PrivateNodeQueryResult @@ -190,10 +211,11 @@ export class TransactionContext { }) } - /** @group Querying */ - async exists( - path: Path.Distinctive> - ): Promise { + /** + * @param path + * @group Querying + */ + async exists(path: Partitioned): Promise { return await this.#query(path, { public: Queries.publicExists(), private: Queries.privateExists(), @@ -202,22 +224,20 @@ export class TransactionContext { /** @group Querying */ async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions: { withItemKind: true } ): Promise async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions: { withItemKind: false } ): Promise + async listDirectory(path: Partitioned): Promise async listDirectory( - path: Path.Directory> - ): Promise - async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions?: { withItemKind: boolean } ): Promise async listDirectory( - path: Path.Directory>, + path: Partitioned, listOptions?: { withItemKind: boolean } ): Promise { if (listOptions?.withItemKind === true) { @@ -239,18 +259,18 @@ export class TransactionContext { /** @group Querying */ async read( arg: - | Path.File> + | PartitionedNonEmpty | { contentCID: CID } | { capsuleCID: CID } | { capsuleKey: Uint8Array }, - dataType: DataType, + dataType: D, options?: { offset?: number; length?: number } ): Promise> async read( arg: - | Path.File> + | PartitionedNonEmpty | { contentCID: CID } | { capsuleCID: CID } | { @@ -293,7 +313,7 @@ export class TransactionContext { AccessKey.fromBytes(arg.capsuleKey), options )(this.#privateContext()) - } else if ('file' in arg || 'directory' in arg) { + } else if (Array.isArray(arg)) { // Public or private from path bytes = await this.#query(arg, { public: Queries.publicRead(options), @@ -301,14 +321,17 @@ export class TransactionContext { }) } else { // ⚠️ - throw new Error('Invalid argument') + throw new TypeError('Invalid argument') } return dataFromBytes(dataType, bytes) } - /** @group Querying */ - async size(path: Path.File>): Promise { + /** + * @param path + * @group Querying + */ + async size(path: PartitionedNonEmpty): Promise { return await this.#query(path, { public: Queries.publicSize(), private: Queries.privateSize(), @@ -317,25 +340,27 @@ export class TransactionContext { // MUTATIONS - /** @group Mutating */ + /** + * @param fromParam + * @param toParam + * @group Mutating + */ async copy( - fromParam: Path.Distinctive>, - toParam: - | Path.File> - | Path.Directory> + fromParam: PartitionedNonEmpty, + toParam: PartitionedNonEmpty | Partitioned ): Promise { const from = fromParam - let to = toParam + const to = toParam - if (Path.isDirectory(fromParam) && Path.isFile(toParam)) - throw new Error('Cannot copy a directory to a file') - if (Path.isFile(fromParam) && Path.isDirectory(toParam)) - to = Path.combine(toParam, Path.file(Path.terminus(from))) + const kind = await this.#query(from, { + public: Queries.publicItemKind(), + private: Queries.privateItemKind(), + }) - if (Path.isFile(from) && Path.isFile(to)) { + if (kind === Path.Kind.File && Path.isPartitionedNonEmpty(to)) { await this.#manualCopyFile(from, to) return - } else if (Path.isDirectory(from) && Path.isDirectory(to)) { + } else if (kind === Path.Kind.Directory) { await this.#manualCopyDirectory(from, to) return } @@ -349,38 +374,47 @@ export class TransactionContext { /** @group Mutating */ cp = this.copy // eslint-disable-line @typescript-eslint/unbound-method - /** @group Mutating */ + /** + * @param path + * @group Mutating + */ async createDirectory( - path: Path.Directory> - ): Promise<{ path: Path.Directory> }> { + path: PartitionedNonEmpty + ): Promise<{ path: PartitionedNonEmpty }> { if (await this.exists(path)) { const newPath = addOrIncreaseNameNumber(path) return await this.createDirectory(newPath) } else { await this.ensureDirectory(path) - return { path: path } + return { path } } } - /** @group Mutating */ + /** + * @param path + * @param dataType + * @param data + * @group Mutating + */ async createFile( - path: Path.File>, + path: PartitionedNonEmpty, dataType: DataType, data: DataForType - ): Promise<{ path: Path.File> }> { + ): Promise<{ path: PartitionedNonEmpty }> { if (await this.exists(path)) { const newPath = addOrIncreaseNameNumber(path) return await this.createFile(newPath, dataType, data) } else { await this.write(path, dataType, data) - return { path: path } + return { path } } } - /** @group Mutating */ - async ensureDirectory( - path: Path.Directory> - ): Promise { + /** + * @param path + * @group Mutating + */ + async ensureDirectory(path: PartitionedNonEmpty): Promise { const partition = determinePartition(path) switch (partition.name) { @@ -407,20 +441,17 @@ export class TransactionContext { /** @group Mutating */ mkdir = this.ensureDirectory // eslint-disable-line @typescript-eslint/unbound-method - /** @group Mutating */ + /** + * @param fromParam + * @param toParam + * @group Mutating + */ async move( - fromParam: Path.Distinctive>, - toParam: - | Path.File> - | Path.Directory> + fromParam: PartitionedNonEmpty, + toParam: PartitionedNonEmpty | Partitioned ): Promise { const from = fromParam - let to = toParam - - if (Path.isDirectory(fromParam) && Path.isFile(toParam)) - throw new Error('Cannot move a directory to a file') - if (Path.isFile(fromParam) && Path.isDirectory(toParam)) - to = Path.combine(toParam, Path.file(Path.terminus(from))) + const to = toParam await this.#manualMove(from, to) } @@ -428,10 +459,11 @@ export class TransactionContext { /** @group Mutating */ mv = this.move // eslint-disable-line @typescript-eslint/unbound-method - /** @group Mutating */ - async remove( - path: Path.Distinctive> - ): Promise { + /** + * @param path + * @group Mutating + */ + async remove(path: PartitionedNonEmpty): Promise { const partition = determinePartition(path) switch (partition.name) { @@ -458,9 +490,13 @@ export class TransactionContext { /** @group Mutating */ rm = this.remove // eslint-disable-line @typescript-eslint/unbound-method - /** @group Mutating */ + /** + * @param path + * @param newName + * @group Mutating + */ async rename( - path: Path.Distinctive>, + path: PartitionedNonEmpty, newName: string ): Promise { const fromPath = path @@ -469,9 +505,14 @@ export class TransactionContext { await this.move(fromPath, toPath) } - /** @group Mutating */ + /** + * @param path + * @param dataType + * @param data + * @group Mutating + */ async write( - path: Path.File>, + path: PartitionedNonEmpty, dataType: DataType, data: DataForType ): Promise { @@ -499,10 +540,240 @@ export class TransactionContext { } } + // IDENTIFIER + + async assignIdentifier(did: string): Promise { + this.#rootTree = await this.#rootTree.replaceDID(did) + } + + identifier(): string | undefined { + return this.#rootTree.did() + } + + // SHARING + + /** + * Check if an exchange key was already registered. + * + * @param exchangePublicKey + * @group Sharing + */ + async isExchangeKeyRegistered( + exchangePublicKey: CryptoKey | Uint8Array + ): Promise { + const publicKey = + exchangePublicKey instanceof CryptoKey + ? await new ExchangeKey(exchangePublicKey).publicKeyModulus() + : exchangePublicKey + + const exr = this.#rootTree.exchangeRoot() + const wnfsStore = Store.wnfs(this.#blockstore) + const items: DirectoryItem[] = await exr.ls([], wnfsStore) + + let isRegistered = false + + for await (const item of items) { + const nestedItems = await exr.ls([item.name], wnfsStore) + for await (const nestedItem of nestedItems) { + const bytes: Uint8Array = await exr.read( + [item.name, nestedItem.name], + wnfsStore + ) + if (Uint8Arr.compare(publicKey, bytes) === 0) { + isRegistered = true + break + } + } + } + + return isRegistered + } + + /** + * Load a shared item. + * + * NOTE: A share can only be received if the exchange key was registered + * and the receiver is in possession of the associated private key. + * + * @param sharerDataRoot The data root CID from the sharer + * @param exchangeKeyPair A RSA-OAEP-256 key pair + * @param exchangeKeyPair.publicKey A RSA-OAEP-256 public key in the form of a `CryptoKey` or its modulus bytes + * @param exchangeKeyPair.privateKey A RSA-OAEP-256 private key in the form of a `CryptoKey` + * @param opts Optional overrides + * @param opts.shareId Specify what shareId to use, otherwise this'll load the last share that was made to the given exchange key. + * @param opts.sharerBlockstore Specify what blockstore to use to load the sharer's file system. + * @param opts.sharerRootTreeClass Specify what root tree class was used for the sharer's file system. + * + * @group Sharing + */ + async receive( + sharerDataRoot: CID, + exchangeKeyPair: { + publicKey: CryptoKey | Uint8Array + privateKey: CryptoKey + }, + opts: { + shareId?: string + sharerBlockstore?: Blockstore + sharerRootTreeClass?: typeof RootTree + } = {} + ): Promise { + const sharerBlockstore = opts.sharerBlockstore ?? this.#blockstore + const { shareId, sharedNode, sharerRootTree } = await loadShare( + sharerDataRoot, + exchangeKeyPair, + { + sharerBlockstore, + shareId: opts.shareId, + sharerRootTreeClass: opts.sharerRootTreeClass, + } + ) + + // Create share context + return new Share( + shareId, + sharerBlockstore, + { '/': { path: Path.root(), node: sharedNode } }, + this.#rng, + sharerRootTree + ) + } + + /** + * Register an exchange key. + * + * @param name A name for the key (using an existing name overrides the old key) + * @param exchangePublicKey A RSA-OAEP-256 public key in the form of a `CryptoKey` or its modulus bytes + * + * @group Sharing + */ + async registerExchangeKey( + name: string, + exchangePublicKey: CryptoKey | Uint8Array + ): Promise { + const publicDir = this.#rootTree.exchangeRoot() + const publicKeyResult = + exchangePublicKey instanceof CryptoKey + ? await new ExchangeKey(exchangePublicKey).publicKeyModulus() + : exchangePublicKey + + // Add public key to exchange root + const result: WnfsPublicResult = await publicDir.write( + [name, 'v1.exchange_key'], + publicKeyResult, + new Date(), + Store.wnfs(this.#blockstore) + ) + + // Replace public root + this.#rootTree = await this.#rootTree.replaceExchangeRoot(result.rootDir) + + // Return public key + return publicKeyResult + } + + /** + * Share a private file or directory. + * + * NOTE: A share can only be received if the exchange key was registered + * and the receiver is in possession of the associated private key. + * + * @param path Path to the private file or directory to share (with 'private' prefix) + * @param receiverDataRoot Data root CID of the receiver + * @param opts Optional overrides + * @param opts.receiverBlockstore Specify what blockstore to use to load the receiver's file system + * @param opts.receiverRootTreeClass Specify what root tree class was used for the receiver's file system + * + * @group Sharing + */ + async share( + path: Partitioned, + receiverDataRoot: CID, + opts: { + receiverBlockstore?: Blockstore + receiverRootTreeClass?: typeof RootTree + } = {} + ): Promise<{ shareId: string }> { + const did = this.identifier() + + if (did === undefined) + throw new Error( + "Identifier wasn't set yet. Set one first using `assignIdentifier`." + ) + + // Access key + const key = await this.capsuleKey(path) + if (key === undefined) throw new Error('Nothing exists at the given path.') + + // Counter + const counter = this.#rootTree.shareCounter() + + // Determine exchange root CID + const receiverBlockstore = opts.receiverBlockstore ?? this.#blockstore + const receiverRootTreeClass = opts.receiverRootTreeClass ?? BasicRootTree + const receiverRootTree = await receiverRootTreeClass.fromCID( + receiverBlockstore, + receiverDataRoot + ) + + const receiverWnfsBlockstore = Store.wnfs(receiverBlockstore) + const exchangeRoot: Uint8Array = await receiverRootTree + .exchangeRoot() + .store(receiverWnfsBlockstore) + + // Create a "merged" blockstore + const sharerBlockstore = Store.wnfs(this.#blockstore) + + const mergedBlockstore = { + async getBlock(cid: Uint8Array): Promise { + if (await sharerBlockstore.hasBlock(cid)) + return await sharerBlockstore.getBlock(cid) + return await receiverWnfsBlockstore.getBlock(cid) + }, + + async hasBlock(cid: Uint8Array): Promise { + if (await sharerBlockstore.hasBlock(cid)) return true + return await receiverWnfsBlockstore.hasBlock(cid) + }, + + putBlockKeyed: sharerBlockstore.putBlockKeyed.bind(sharerBlockstore), + putBlock: sharerBlockstore.putBlock?.bind(sharerBlockstore), + } + + // Create share + const forest: PrivateForest = await share( + AccessKey.fromBytes(key), + counter, + did, + exchangeRoot, + this.#rootTree.privateForest(), + mergedBlockstore + ) + + // Update counter + this.#rootTree = await this.#rootTree.increaseShareCounter() + + // Modification + const change = { + type: Mutations.TYPES.Shared, + path, + } + + this.#modifications.add(change) + + // Replace root tree + this.#rootTree = await this.#rootTree.replacePrivateForest(forest, [change]) + + // Fin + return { + shareId: counter.toString(), + } + } + // ㊙️ ▒▒ QUERIES async #query( - path: Path.Distinctive>, + path: Partitioned, queryFunctions: { public: Queries.Public private: Queries.Private @@ -532,15 +803,15 @@ export class TransactionContext { // ㊙️ ▒▒ MUTATIONS async #manualCopyFile( - from: Path.File>, - to: Path.File> + from: PartitionedNonEmpty, + to: PartitionedNonEmpty ): Promise { await this.write(to, 'bytes', await this.read(from, 'bytes')) } async #manualCopyDirectory( - from: Path.Directory>, - to: Path.Directory> + from: PartitionedNonEmpty, + to: Partitioned ): Promise { if (Path.isPartitionedNonEmpty(to)) await this.ensureDirectory(to) @@ -558,12 +829,12 @@ export class TransactionContext { item.kind === 'directory' ? await this.#manualCopyDirectory( - Path.combine(from, Path.directory(item.name)), - Path.combine(to, Path.directory(item.name)) + Path.combine(from, [item.name]), + Path.combine(to, [item.name]) ) : await this.#manualCopyFile( - Path.combine(from, Path.file(item.name)), - Path.combine(to, Path.file(item.name)) + Path.combine(from, [item.name]), + Path.combine(to, [item.name]) ) }, Promise.resolve() @@ -571,28 +842,26 @@ export class TransactionContext { } async #manualMove( - from: Path.Distinctive>, - to: - | Path.File> - | Path.Directory> + from: PartitionedNonEmpty, + to: PartitionedNonEmpty | Partitioned ): Promise { await this.copy(from, to) await this.remove(from) } async #publicMutation( - path: Path.Distinctive>, + path: Partitioned, mut: Mutations.Public, mutType: MutationType ): Promise { const mod = { type: mutType, - path: path, + path, } const result = await mut({ blockstore: this.#blockstore, - pathSegments: Path.unwrap(Path.removePartition(path)), + pathSegments: Path.removePartition(path), rootTree: this.#rootTree, }) @@ -606,14 +875,14 @@ export class TransactionContext { } async #privateMutation( - path: Path.Distinctive>, + path: Partitioned, mut: Mutations.Private, mutType: MutationType ): Promise { const priv = findPrivateNode(path, this.#privateNodes) const change = { type: mutType, - path: path, + path, } // Perform mutation diff --git a/packages/nest/src/types.ts b/packages/nest/src/types.ts index 18490cc..4314e7b 100644 --- a/packages/nest/src/types.ts +++ b/packages/nest/src/types.ts @@ -27,11 +27,11 @@ export interface DirectoryItem { export type DirectoryItemWithKind = DirectoryItem & { kind: Path.Kind - path: Path.Distinctive> + path: Path.PartitionedNonEmpty } export interface Modification { - path: Path.Distinctive> + path: Path.Partitioned type: MutationType } @@ -48,18 +48,18 @@ export type MutationResult< ? PrivateMutationResult : never -export type MutationType = 'added-or-updated' | 'removed' +export type MutationType = 'added-or-updated' | 'removed' | 'shared' export type PartitionDiscovery

= P extends Path.Public ? { name: 'public' - path: Path.File> + path: Path.Partitioned segments: Path.Segments } : P extends Path.Private ? { name: 'private' - path: Path.File> + path: Path.Partitioned segments: Path.Segments } : never @@ -68,13 +68,13 @@ export type PartitionDiscoveryNonEmpty

= P extends Path.Public ? { name: 'public' - path: Path.File> + path: Path.PartitionedNonEmpty segments: Path.Segments } : P extends Path.Private ? { name: 'private' - path: Path.File> + path: Path.PartitionedNonEmpty segments: Path.Segments } : never diff --git a/packages/nest/src/types/internal.ts b/packages/nest/src/types/internal.ts index 8d175b9..1748ee1 100644 --- a/packages/nest/src/types/internal.ts +++ b/packages/nest/src/types/internal.ts @@ -12,7 +12,7 @@ export type MountedPrivateNodes = Record export interface MountedPrivateNode { node: PrivateNode - path: Path.Distinctive + path: Path.Segments } export type PrivateNodeQueryResult = MountedPrivateNode & { diff --git a/packages/nest/src/unix.ts b/packages/nest/src/unix.ts index 8bf2dcb..adc5346 100644 --- a/packages/nest/src/unix.ts +++ b/packages/nest/src/unix.ts @@ -14,6 +14,9 @@ import * as Store from './store.js' /** * Create a UnixFS directory. + * + * @param currentTime + * @param links */ export function createDirectory( currentTime: Date, @@ -29,6 +32,12 @@ export function createDirectory( /** * Get the bytes of a UnixFS file. + * + * @param cid + * @param store + * @param options + * @param options.offset + * @param options.length */ export async function exportFile( cid: CID, @@ -51,6 +60,9 @@ export async function exportFile( /** * Get the CID for some file bytes. + * + * @param bytes + * @param store */ export async function importFile( bytes: Uint8Array, @@ -63,29 +75,35 @@ export async function importFile( /** * Insert a node into UnixFS tree, creating directories when needed * and overwriting content. + * + * @param itemKind + * @param node + * @param path + * @param store + * @param fileCID */ export async function insertNodeIntoTree( + itemKind: Path.Kind, node: PBNode, - path: Path.Distinctive, + path: Path.Segments, store: Blockstore, fileCID?: CID ): Promise { - const pathKind = Path.kind(path) - const pathParts = Path.unwrap(path) - const name = pathParts[0] + const name = path[0] const link = node.Links.find((l) => l.Name === name) // Directory // --------- - if (Path.length(path) > 1) { + if (path.length > 1) { const dirNode: PBNode = link?.Hash === undefined ? createDirectory(new Date()) : await load(link.Hash, store) const newDirNode = await insertNodeIntoTree( + itemKind, dirNode, - Path.fromKind(pathKind, ...pathParts.slice(1)), + path.slice(1), store, fileCID ) @@ -107,7 +125,7 @@ export async function insertNodeIntoTree( // Last part of path // ----------------- // Directory - if (pathKind === 'directory') { + if (itemKind === Path.Kind.Directory) { if (link !== undefined) return node const dirNode = createDirectory(new Date()) @@ -131,6 +149,9 @@ export async function insertNodeIntoTree( /** * Load a UnixFS node. + * + * @param cid + * @param store */ export async function load(cid: CID, store: Blockstore): Promise { return DagPB.decode(await store.get(cid)) @@ -138,20 +159,22 @@ export async function load(cid: CID, store: Blockstore): Promise { /** * Remove a node from a UnixFS tree. + * + * @param node + * @param path + * @param store */ export async function removeNodeFromTree( node: PBNode, - path: Path.Distinctive, + path: Path.Segments, store: Blockstore ): Promise { - const pathKind = Path.kind(path) - const pathParts = Path.unwrap(path) - const name = pathParts[0] + const name = path[0] const link = node.Links.find((l) => l.Name === name) // Directory // --------- - if (Path.length(path) > 1) { + if (path.length > 1) { let dirNode: PBNode if (link?.Hash === undefined) { @@ -162,7 +185,7 @@ export async function removeNodeFromTree( const newDirNode = await removeNodeFromTree( dirNode, - Path.fromKind(pathKind, ...pathParts.slice(1)), + Path.removePartition(path), store ) @@ -188,14 +211,31 @@ export async function removeNodeFromTree( // ㊙️ +/** + * + * @param links + * @param name + * @param hash + */ function addLink(links: PBLink[], name: string, hash: CID): PBLink[] { return [...links, DagPB.createLink(name, 0, hash)].sort(linkSorter) } +/** + * + * @param links + * @param name + * @param hash + */ function replaceLinkHash(links: PBLink[], name: string, hash: CID): PBLink[] { return links.map((l) => (l.Name === name ? { ...l, Hash: hash } : l)) } +/** + * + * @param a + * @param b + */ function linkSorter(a: PBLink, b: PBLink): number { if ((a.Name ?? '') > (b.Name ?? '')) return 1 if ((a.Name ?? '') < (b.Name ?? '')) return -1 diff --git a/packages/nest/test/class.test.ts b/packages/nest/test/class.test.ts index 512d149..3aa7aa7 100644 --- a/packages/nest/test/class.test.ts +++ b/packages/nest/test/class.test.ts @@ -10,6 +10,7 @@ import { MemoryBlockstore } from 'blockstore-core/memory' import * as Path from '../src/path.js' import type { Modification } from '../src/types.js' +import { ExchangeKey } from '../src/exchange-key.js' import { FileSystem } from '../src/class.js' import { @@ -22,7 +23,7 @@ describe('File System Class', () => { let blockstore: Blockstore let fs: FileSystem let _mounts: Array<{ - path: Path.Distinctive + path: Path.Segments capsuleKey: Uint8Array }> @@ -41,15 +42,19 @@ describe('File System Class', () => { ...fsOpts, }) - _mounts = await fs.mountPrivateNodes([{ path: Path.root() }]) + _mounts = [ + await fs.createPrivateNode({ + path: Path.root(), + }), + ] }) // LOADING // ------- it('loads a file system and capsule keys + content cids', async () => { - const publicPath = Path.file('public', 'nested-public', 'public.txt') - const privatePath = Path.file('private', 'nested-private', 'private.txt') + const publicPath = Path.pub('nested-public', 'public.txt') + const privatePath = Path.priv('nested-private', 'private.txt') await fs.write(publicPath, 'utf8', 'public') const { capsuleKey, dataRoot } = await fs.write( @@ -76,19 +81,19 @@ describe('File System Class', () => { }) it('loads a file system and capsule keys + content cids after multiple changes', async () => { - const publicPath = Path.file('public', 'nested-public', 'public.txt') - const privatePath = Path.file('private', 'nested-private', 'private.txt') + const publicPath = Path.pub('nested-public', 'public.txt') + const privatePath = Path.priv('nested-private', 'private.txt') await fs.write(publicPath, 'utf8', 'public') await fs.write(privatePath, 'utf8', 'private') - await fs.write(Path.file('public', 'part.two'), 'utf8', 'public-2') + await fs.write(Path.pub('part.two'), 'utf8', 'public-2') const { dataRoot } = await fs.write( - Path.file('private', 'part.two'), + Path.priv('part.two'), 'utf8', 'private-2' ) - const capsuleKey = await fs.capsuleKey(Path.directory('private')) + const capsuleKey = await fs.capsuleKey(Path.priv()) const loadedFs = await FileSystem.fromCID(dataRoot, { blockstore, @@ -106,8 +111,8 @@ describe('File System Class', () => { }) it('loads a private file system given an older capsule key', async () => { - const privatePath = Path.file('private', 'nested-private', 'private.txt') - const oldCapsuleKey = await fs.capsuleKey(Path.directory('private')) + const privatePath = Path.priv('nested-private', 'private.txt') + const oldCapsuleKey = await fs.capsuleKey(Path.priv()) const { dataRoot } = await fs.write(privatePath, 'utf8', 'private') @@ -135,7 +140,7 @@ describe('File System Class', () => { // ----------------- it('writes and reads public files', async () => { - const path = Path.file('public', 'a') + const path = Path.pub('a') const bytes = new TextEncoder().encode('🚀') await fs.write(path, 'bytes', bytes) @@ -145,7 +150,7 @@ describe('File System Class', () => { }) it('writes and reads private files', async () => { - const path = Path.file('private', 'a') + const path = Path.priv('a') await fs.write(path, 'json', { foo: 'bar', a: 1 }) @@ -153,8 +158,8 @@ describe('File System Class', () => { }) it('writes and reads deeply nested files', async () => { - const pathPublic = Path.file('public', 'a', 'b', 'c.txt') - const pathPrivate = Path.file('private', 'a', 'b', 'c.txt') + const pathPublic = Path.pub('a', 'b', 'c.txt') + const pathPrivate = Path.priv('a', 'b', 'c.txt') await fs.write(pathPublic, 'utf8', '🌍') await fs.write(pathPrivate, 'utf8', '🔐') @@ -171,41 +176,38 @@ describe('File System Class', () => { }) it('creates files', async () => { - await fs.write(Path.file('private', 'File'), 'utf8', '🧞') - await fs.createFile(Path.file('private', 'File'), 'utf8', '🧞') + await fs.write(Path.priv('File'), 'utf8', '🧞') + await fs.createFile(Path.priv('File'), 'utf8', '🧞') - assert.equal(await fs.exists(Path.file('private', 'File (1)')), true) + assert.equal(await fs.exists(Path.priv('File (1)')), true) - await fs.createFile(Path.file('private', 'File'), 'utf8', '🧞') + await fs.createFile(Path.priv('File'), 'utf8', '🧞') - assert.equal(await fs.exists(Path.file('private', 'File (2)')), true) + assert.equal(await fs.exists(Path.priv('File (2)')), true) - await fs.createFile(Path.file('private', 'File (1)'), 'utf8', '🧞') + await fs.createFile(Path.priv('File (1)'), 'utf8', '🧞') - assert.equal(await fs.read(Path.file('private', 'File (3)'), 'utf8'), '🧞') + assert.equal(await fs.read(Path.priv('File (3)'), 'utf8'), '🧞') }) it('creates files with extensions', async () => { - await fs.write(Path.file('private', 'File.7z'), 'utf8', '🧞') - await fs.createFile(Path.file('private', 'File.7z'), 'utf8', '🧞') + await fs.write(Path.priv('File.7z'), 'utf8', '🧞') + await fs.createFile(Path.priv('File.7z'), 'utf8', '🧞') - assert.equal(await fs.exists(Path.file('private', 'File (1).7z')), true) + assert.equal(await fs.exists(Path.priv('File (1).7z')), true) - await fs.createFile(Path.file('private', 'File.7z'), 'utf8', '🧞') + await fs.createFile(Path.priv('File.7z'), 'utf8', '🧞') - assert.equal(await fs.exists(Path.file('private', 'File (2).7z')), true) + assert.equal(await fs.exists(Path.priv('File (2).7z')), true) - await fs.createFile(Path.file('private', 'File (1).7z'), 'utf8', '🧞') + await fs.createFile(Path.priv('File (1).7z'), 'utf8', '🧞') - assert.equal( - await fs.read(Path.file('private', 'File (3).7z'), 'utf8'), - '🧞' - ) + assert.equal(await fs.read(Path.priv('File (3).7z'), 'utf8'), '🧞') }) it('retrieves public content using a CID', async () => { const { contentCID, capsuleCID } = await fs.write( - Path.file('public', 'file'), + Path.pub('file'), 'utf8', '🌍' ) @@ -216,18 +218,14 @@ describe('File System Class', () => { }) it('retrieves private content using a capsule key', async () => { - const { capsuleKey } = await fs.write( - Path.file('private', 'file'), - 'utf8', - '🔐' - ) + const { capsuleKey } = await fs.write(Path.priv('file'), 'utf8', '🔐') assert.equal(await fs.read({ capsuleKey }, 'utf8'), '🔐') }) it('can read partial public content bytes', async () => { const { contentCID, capsuleCID } = await fs.write( - Path.file('public', 'file'), + Path.pub('file'), 'bytes', new Uint8Array([16, 24, 32]) ) @@ -249,7 +247,7 @@ describe('File System Class', () => { it('can read partial utf8 public content', async () => { const { contentCID, capsuleCID } = await fs.write( - Path.file('public', 'file'), + Path.pub('file'), 'utf8', 'abc' ) @@ -267,7 +265,7 @@ describe('File System Class', () => { it('can read partial private content bytes', async () => { const { capsuleKey } = await fs.write( - Path.file('private', 'file'), + Path.priv('file'), 'bytes', new Uint8Array([16, 24, 32]) ) @@ -281,11 +279,7 @@ describe('File System Class', () => { }) it('can read partial utf8 private content', async () => { - const { capsuleKey } = await fs.write( - Path.file('private', 'file'), - 'utf8', - 'abc' - ) + const { capsuleKey } = await fs.write(Path.priv('file'), 'utf8', 'abc') assert.equal( await fs.read({ capsuleKey }, 'utf8', { offset: 1, length: 1 }), @@ -297,57 +291,46 @@ describe('File System Class', () => { // ----------- it('ensures directories and checks for existence', async () => { - await fs.ensureDirectory(Path.directory('public', 'a')) - await fs.ensureDirectory(Path.directory('public', 'a', 'b')) - await fs.ensureDirectory(Path.directory('public', 'a', 'b', 'c')) + await fs.ensureDirectory(Path.pub('a')) + await fs.ensureDirectory(Path.pub('a', 'b')) + await fs.ensureDirectory(Path.pub('a', 'b', 'c')) - await fs.ensureDirectory(Path.directory('private', 'a')) - await fs.ensureDirectory(Path.directory('private', 'a', 'b')) - await fs.ensureDirectory(Path.directory('private', 'a', 'b', 'c')) + await fs.ensureDirectory(Path.priv('a')) + await fs.ensureDirectory(Path.priv('a', 'b')) + await fs.ensureDirectory(Path.priv('a', 'b', 'c')) - assert.equal(await fs.exists(Path.directory('public', 'a')), true) - assert.equal(await fs.exists(Path.directory('public', 'a', 'b')), true) - assert.equal(await fs.exists(Path.directory('public', 'a', 'b', 'c')), true) + assert.equal(await fs.exists(Path.pub('a')), true) + assert.equal(await fs.exists(Path.pub('a', 'b')), true) + assert.equal(await fs.exists(Path.pub('a', 'b', 'c')), true) - assert.equal(await fs.exists(Path.directory('private', 'a')), true) - assert.equal(await fs.exists(Path.directory('private', 'a', 'b')), true) - assert.equal( - await fs.exists(Path.directory('private', 'a', 'b', 'c')), - true - ) + assert.equal(await fs.exists(Path.priv('a')), true) + assert.equal(await fs.exists(Path.priv('a', 'b')), true) + assert.equal(await fs.exists(Path.priv('a', 'b', 'c')), true) // Does not throw for existing dirs - await fs.ensureDirectory(Path.directory('public', 'a')) - await fs.ensureDirectory(Path.directory('public', 'a', 'b')) + await fs.ensureDirectory(Path.pub('a')) + await fs.ensureDirectory(Path.pub('a', 'b')) - await fs.ensureDirectory(Path.directory('private', 'a')) - await fs.ensureDirectory(Path.directory('private', 'a', 'b')) + await fs.ensureDirectory(Path.priv('a')) + await fs.ensureDirectory(Path.priv('a', 'b')) - await assertUnixFsDirectory( - { blockstore }, - fs, - Path.directory('public', 'a') - ) - await assertUnixFsDirectory( - { blockstore }, - fs, - Path.directory('public', 'a', 'b') - ) + await assertUnixFsDirectory({ blockstore }, fs, Path.pub('a')) + await assertUnixFsDirectory({ blockstore }, fs, Path.pub('a', 'b')) }) it('lists public directories', async () => { - await fs.ensureDirectory(Path.directory('public', 'a')) - await fs.write(Path.file('public', 'a-file'), 'utf8', '🧞') - await fs.ensureDirectory(Path.directory('public', 'a', 'b')) - await fs.write(Path.file('public', 'a', 'b-file'), 'utf8', '💃') + await fs.ensureDirectory(Path.pub('a')) + await fs.write(Path.pub('a-file'), 'utf8', '🧞') + await fs.ensureDirectory(Path.pub('a', 'b')) + await fs.write(Path.pub('a', 'b-file'), 'utf8', '💃') - const a = await fs.listDirectory(Path.directory('public')) + const a = await fs.listDirectory(Path.pub()) assert.deepEqual( a.map((i) => i.name), ['a', 'a-file'] ) - const b = await fs.listDirectory(Path.directory('public', 'a')) + const b = await fs.listDirectory(Path.pub('a')) assert.deepEqual( b.map((i) => i.name), ['b', 'b-file'] @@ -355,17 +338,17 @@ describe('File System Class', () => { }) it('lists public directories with item kind', async () => { - const pathDirA = Path.directory('public', 'a') - const pathFileA = Path.file('public', 'a-file') - const pathDirB = Path.directory('public', 'a', 'b') - const pathFileB = Path.file('public', 'a', 'b-file') + const pathDirA = Path.pub('a') + const pathFileA = Path.pub('a-file') + const pathDirB = Path.pub('a', 'b') + const pathFileB = Path.pub('a', 'b-file') await fs.ensureDirectory(pathDirA) await fs.write(pathFileA, 'utf8', '🧞') await fs.ensureDirectory(pathDirB) await fs.write(pathFileB, 'utf8', '💃') - const a = await fs.listDirectory(Path.directory('public'), { + const a = await fs.listDirectory(Path.pub(), { withItemKind: true, }) assert.deepEqual( @@ -377,7 +360,7 @@ describe('File System Class', () => { [pathDirA, pathFileA] ) - const b = await fs.listDirectory(Path.directory('public', 'a'), { + const b = await fs.listDirectory(Path.pub('a'), { withItemKind: true, }) assert.deepEqual( @@ -391,18 +374,18 @@ describe('File System Class', () => { }) it('lists private directories', async () => { - await fs.ensureDirectory(Path.directory('private', 'a')) - await fs.write(Path.file('private', 'a-file'), 'utf8', '🧞') - await fs.ensureDirectory(Path.directory('private', 'a', 'b')) - await fs.write(Path.file('private', 'a', 'b-file'), 'utf8', '💃') + await fs.ensureDirectory(Path.priv('a')) + await fs.write(Path.priv('a-file'), 'utf8', '🧞') + await fs.ensureDirectory(Path.priv('a', 'b')) + await fs.write(Path.priv('a', 'b-file'), 'utf8', '💃') - const a = await fs.listDirectory(Path.directory('private')) + const a = await fs.listDirectory(Path.priv()) assert.deepEqual( a.map((i) => i.name), ['a', 'a-file'] ) - const b = await fs.listDirectory(Path.directory('private', 'a')) + const b = await fs.listDirectory(Path.priv('a')) assert.deepEqual( b.map((i) => i.name), ['b', 'b-file'] @@ -410,17 +393,17 @@ describe('File System Class', () => { }) it('lists private directories with item kind', async () => { - const pathDirA = Path.directory('private', 'a') - const pathFileA = Path.file('private', 'a-file') - const pathDirB = Path.directory('private', 'a', 'b') - const pathFileB = Path.file('private', 'a', 'b-file') + const pathDirA = Path.priv('a') + const pathFileA = Path.priv('a-file') + const pathDirB = Path.priv('a', 'b') + const pathFileB = Path.priv('a', 'b-file') await fs.ensureDirectory(pathDirA) await fs.write(pathFileA, 'utf8', '🧞') await fs.ensureDirectory(pathDirB) await fs.write(pathFileB, 'utf8', '💃') - const a = await fs.listDirectory(Path.directory('private'), { + const a = await fs.listDirectory(Path.priv(), { withItemKind: true, }) assert.deepEqual( @@ -432,7 +415,7 @@ describe('File System Class', () => { [pathDirA, pathFileA] ) - const b = await fs.listDirectory(Path.directory('private', 'a'), { + const b = await fs.listDirectory(Path.priv('a'), { withItemKind: true, }) assert.deepEqual( @@ -446,58 +429,25 @@ describe('File System Class', () => { }) it('creates directories', async () => { - await fs.ensureDirectory(Path.directory('private', 'Directory')) - await fs.createDirectory(Path.directory('private', 'Directory')) + await fs.ensureDirectory(Path.priv('Directory')) + await fs.createDirectory(Path.priv('Directory')) - assert.equal( - await fs.exists(Path.directory('private', 'Directory (1)')), - true - ) + assert.equal(await fs.exists(Path.priv('Directory (1)')), true) - await fs.createDirectory(Path.directory('private', 'Directory')) + await fs.createDirectory(Path.priv('Directory')) - assert.equal( - await fs.exists(Path.directory('private', 'Directory (2)')), - true - ) + assert.equal(await fs.exists(Path.priv('Directory (2)')), true) - await fs.createDirectory(Path.directory('private', 'Directory (1)')) + await fs.createDirectory(Path.priv('Directory (1)')) - assert.equal( - await fs.exists(Path.directory('private', 'Directory (3)')), - true - ) - }) - - it('creates directories with extensions', async () => { - await fs.ensureDirectory(Path.directory('private', 'Directory.7z')) - await fs.createDirectory(Path.directory('private', 'Directory.7z')) - - assert.equal( - await fs.exists(Path.directory('private', 'Directory.7z (1)')), - true - ) - - await fs.createDirectory(Path.directory('private', 'Directory.7z')) - - assert.equal( - await fs.exists(Path.directory('private', 'Directory.7z (2)')), - true - ) - - await fs.createDirectory(Path.directory('private', 'Directory.7z (1)')) - - assert.equal( - await fs.exists(Path.directory('private', 'Directory.7z (3)')), - true - ) + assert.equal(await fs.exists(Path.priv('Directory (3)')), true) }) // CIDS & REFS // ----------- it('can get a content CID for an existing public file', async () => { - const path = Path.file('public', 'a', 'b', 'file') + const path = Path.pub('a', 'b', 'file') const { contentCID } = await fs.write(path, 'utf8', '💃') const cid = await fs.contentCID(path) @@ -506,7 +456,7 @@ describe('File System Class', () => { }) it('can get a capsule CID for an existing public file', async () => { - const path = Path.file('public', 'a', 'b', 'file') + const path = Path.pub('a', 'b', 'file') const { capsuleCID } = await fs.write(path, 'utf8', '💃') const cid = await fs.capsuleCID(path) @@ -515,7 +465,7 @@ describe('File System Class', () => { }) it('can get a capsule CID for an existing public directory', async () => { - const path = Path.directory('public', 'a', 'b', 'directory') + const path = Path.pub('a', 'b', 'directory') const { capsuleCID } = await fs.ensureDirectory(path) const cid = await fs.capsuleCID(path) @@ -524,7 +474,7 @@ describe('File System Class', () => { }) it('can get a capsule key for an existing private file', async () => { - const path = Path.file('private', 'a', 'b', 'file') + const path = Path.priv('a', 'b', 'file') const { capsuleKey } = await fs.write(path, 'utf8', '💃') const key = await fs.capsuleKey(path) @@ -536,7 +486,7 @@ describe('File System Class', () => { }) it('can get a capsule CID for an existing private directory', async () => { - const path = Path.directory('private', 'a', 'b', 'directory') + const path = Path.priv('a', 'b', 'directory') const { capsuleKey } = await fs.ensureDirectory(path) const key = await fs.capsuleKey(path) @@ -548,7 +498,7 @@ describe('File System Class', () => { }) it('can get a capsule CID for a mounted private directory', async () => { - const path = Path.directory('private') + const path = Path.priv() const key = await fs.capsuleKey(path) assert.notEqual( @@ -561,7 +511,7 @@ describe('File System Class', () => { // ---- it('returns the size of public files', async () => { - const path = Path.file('public', 'file') + const path = Path.pub('file') await fs.write(path, 'bytes', new Uint8Array([1, 2, 3])) const size = await fs.size(path) @@ -570,7 +520,7 @@ describe('File System Class', () => { }) it('returns the size of private files', async () => { - const path = Path.file('private', 'file') + const path = Path.priv('file') await fs.write(path, 'bytes', new Uint8Array([1, 2, 3, 4])) const size = await fs.size(path) @@ -582,7 +532,7 @@ describe('File System Class', () => { // ------ it('removes public files', async () => { - const path = Path.file('public', 'a', 'b', 'file') + const path = Path.pub('a', 'b', 'file') await fs.write(path, 'utf8', '💃') await fs.remove(path) @@ -593,7 +543,7 @@ describe('File System Class', () => { }) it('removes private files', async () => { - const path = Path.file('private', 'a', 'b', 'file') + const path = Path.priv('a', 'b', 'file') await fs.write(path, 'utf8', '💃') await fs.remove(path) @@ -602,7 +552,7 @@ describe('File System Class', () => { }) it('removes public directories', async () => { - const path = Path.directory('public', 'a', 'b', 'directory') + const path = Path.pub('a', 'b', 'directory') await fs.ensureDirectory(path) await fs.remove(path) @@ -613,7 +563,7 @@ describe('File System Class', () => { }) it('removes private directories', async () => { - const path = Path.directory('private', 'a', 'b', 'directory') + const path = Path.priv('a', 'b', 'directory') await fs.ensureDirectory(path) await fs.remove(path) @@ -625,8 +575,8 @@ describe('File System Class', () => { // ------- it('copies public files', async () => { - const fromPath = Path.file('public', 'a', 'b', 'file') - const toPath = Path.file('public', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.pub('a', 'b', 'file') + const toPath = Path.pub('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.copy(fromPath, toPath) @@ -635,10 +585,10 @@ describe('File System Class', () => { }) it('copies public files into a directory that already exists', async () => { - await fs.ensureDirectory(Path.directory('public', 'a', 'b', 'c', 'd')) + await fs.ensureDirectory(Path.pub('a', 'b', 'c', 'd')) - const fromPath = Path.file('public', 'a', 'b', 'file') - const toPath = Path.file('public', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.pub('a', 'b', 'file') + const toPath = Path.pub('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.copy(fromPath, toPath) @@ -647,8 +597,8 @@ describe('File System Class', () => { }) it('copies private files', async () => { - const fromPath = Path.file('private', 'a', 'b', 'file') - const toPath = Path.file('private', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.priv('a', 'b', 'file') + const toPath = Path.priv('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.copy(fromPath, toPath) @@ -657,10 +607,10 @@ describe('File System Class', () => { }) it('copies private files into a directory that already exists', async () => { - await fs.ensureDirectory(Path.directory('private', 'a', 'b', 'c', 'd')) + await fs.ensureDirectory(Path.priv('a', 'b', 'c', 'd')) - const fromPath = Path.file('private', 'a', 'b', 'file') - const toPath = Path.file('private', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.priv('a', 'b', 'file') + const toPath = Path.priv('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.copy(fromPath, toPath) @@ -669,103 +619,71 @@ describe('File System Class', () => { }) it('copies public directories', async () => { - const fromPath = Path.directory('public', 'b', 'c') - const toPath = Path.directory('public', 'a', 'b', 'c', 'd', 'e') + const fromPath = Path.pub('b', 'c') + const toPath = Path.pub('a', 'b', 'c', 'd', 'e') - await fs.write(Path.combine(fromPath, Path.file('file')), 'utf8', '💃') - await fs.write( - Path.combine(fromPath, Path.file('nested', 'file')), - 'utf8', - '🧞' - ) - await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-empty')) - ) + await fs.write(Path.combine(fromPath, ['file']), 'utf8', '💃') + await fs.write(Path.combine(fromPath, ['nested', 'file']), 'utf8', '🧞') + await fs.ensureDirectory(Path.combine(fromPath, ['nested-empty'])) await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-2', 'deeply-nested')) + Path.combine(fromPath, ['nested-2', 'deeply-nested']) ) await fs.copy(fromPath, toPath) - assert.equal( - await fs.read(Path.combine(toPath, Path.file('file')), 'utf8'), - '💃' - ) + assert.equal(await fs.read(Path.combine(toPath, ['file']), 'utf8'), '💃') assert.equal( - await fs.read(Path.combine(toPath, Path.file('nested', 'file')), 'utf8'), + await fs.read(Path.combine(toPath, ['nested', 'file']), 'utf8'), '🧞' ) - assert.equal( - await fs.exists(Path.combine(toPath, Path.directory('nested-empty'))), - true - ) + assert.equal(await fs.exists(Path.combine(toPath, ['nested-empty'])), true) assert.equal( - await fs.exists( - Path.combine(toPath, Path.directory('nested-2', 'deeply-nested')) - ), + await fs.exists(Path.combine(toPath, ['nested-2', 'deeply-nested'])), true ) - await fs.copy(Path.directory('public', 'a', 'b'), Path.directory('public')) + await fs.copy(Path.pub('a', 'b'), Path.pub()) assert.equal( - await fs.exists( - Path.directory('public', 'b', 'c', 'nested-2', 'deeply-nested') - ), + await fs.exists(Path.pub('b', 'c', 'nested-2', 'deeply-nested')), true ) }) it('copies private directories', async () => { - const fromPath = Path.directory('private', 'b', 'c') - const toPath = Path.directory('private', 'a', 'b', 'c', 'd', 'e') + const fromPath = Path.priv('b', 'c') + const toPath = Path.priv('a', 'b', 'c', 'd', 'e') - await fs.write(Path.combine(fromPath, Path.file('file')), 'utf8', '💃') - await fs.write( - Path.combine(fromPath, Path.file('nested', 'file')), - 'utf8', - '🧞' - ) + await fs.write(Path.combine(fromPath, ['file']), 'utf8', '💃') + await fs.write(Path.combine(fromPath, ['nested', 'file']), 'utf8', '🧞') + await fs.ensureDirectory(Path.combine(fromPath, ['nested-empty'])) await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-empty')) - ) - await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-2', 'deeply-nested')) + Path.combine(fromPath, ['nested-2', 'deeply-nested']) ) await fs.copy(fromPath, toPath) - assert.equal( - await fs.read(Path.combine(toPath, Path.file('file')), 'utf8'), - '💃' - ) + assert.equal(await fs.read(Path.combine(toPath, ['file']), 'utf8'), '💃') assert.equal( - await fs.read(Path.combine(toPath, Path.file('nested', 'file')), 'utf8'), + await fs.read(Path.combine(toPath, ['nested', 'file']), 'utf8'), '🧞' ) - assert.equal( - await fs.exists(Path.combine(toPath, Path.directory('nested-empty'))), - true - ) + assert.equal(await fs.exists(Path.combine(toPath, ['nested-empty'])), true) assert.equal( - await fs.exists( - Path.combine(toPath, Path.directory('nested-2', 'deeply-nested')) - ), + await fs.exists(Path.combine(toPath, ['nested-2', 'deeply-nested'])), true ) - await fs.copy(Path.directory('private', 'a'), Path.directory('private')) + await fs.copy(Path.priv('a'), Path.priv()) assert.equal( - await fs.exists( - Path.directory('private', 'b', 'c', 'nested-2', 'deeply-nested') - ), + await fs.exists(Path.priv('b', 'c', 'nested-2', 'deeply-nested')), true ) }) @@ -774,8 +692,8 @@ describe('File System Class', () => { // ------ it('moves public files', async () => { - const fromPath = Path.file('public', 'a', 'b', 'file') - const toPath = Path.file('public', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.pub('a', 'b', 'file') + const toPath = Path.pub('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.move(fromPath, toPath) @@ -785,8 +703,8 @@ describe('File System Class', () => { }) it('moves private files', async () => { - const fromPath = Path.file('private', 'a', 'b', 'file') - const toPath = Path.file('private', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.priv('a', 'b', 'file') + const toPath = Path.priv('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.move(fromPath, toPath) @@ -796,150 +714,100 @@ describe('File System Class', () => { }) it('moves public directories', async () => { - const fromPath = Path.directory('public', 'b', 'c') - const toPath = Path.directory('public', 'a', 'b', 'c', 'd', 'e') + const fromPath = Path.pub('b', 'c') + const toPath = Path.pub('a', 'b', 'c', 'd', 'e') - await fs.write(Path.combine(fromPath, Path.file('file')), 'utf8', '💃') - await fs.write( - Path.combine(fromPath, Path.file('nested', 'file')), - 'utf8', - '🧞' - ) - await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-empty')) - ) + await fs.write(Path.combine(fromPath, ['file']), 'utf8', '💃') + await fs.write(Path.combine(fromPath, ['nested', 'file']), 'utf8', '🧞') + await fs.ensureDirectory(Path.combine(fromPath, ['nested-empty'])) await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-2', 'deeply-nested')) + Path.combine(fromPath, ['nested-2', 'deeply-nested']) ) await fs.move(fromPath, toPath) - assert.equal( - await fs.read(Path.combine(toPath, Path.file('file')), 'utf8'), - '💃' - ) + assert.equal(await fs.read(Path.combine(toPath, ['file']), 'utf8'), '💃') assert.equal( - await fs.read(Path.combine(toPath, Path.file('nested', 'file')), 'utf8'), + await fs.read(Path.combine(toPath, ['nested', 'file']), 'utf8'), '🧞' ) - assert.equal( - await fs.exists(Path.combine(toPath, Path.directory('nested-empty'))), - true - ) + assert.equal(await fs.exists(Path.combine(toPath, ['nested-empty'])), true) assert.equal( - await fs.exists( - Path.combine(toPath, Path.directory('nested-2', 'deeply-nested')) - ), + await fs.exists(Path.combine(toPath, ['nested-2', 'deeply-nested'])), true ) assert.equal(await fs.exists(fromPath), false) - await fs.move(Path.directory('public', 'a'), Path.directory('public')) + await fs.move(Path.pub('a'), Path.pub()) assert.equal( - await fs.exists( - Path.directory('public', 'b', 'c', 'nested-2', 'deeply-nested') - ), + await fs.exists(Path.pub('b', 'c', 'nested-2', 'deeply-nested')), false ) - assert.equal(await fs.exists(Path.directory('public', 'a')), false) + assert.equal(await fs.exists(Path.pub('a')), false) assert.equal( await fs.exists( - Path.directory( - 'public', - 'a', - 'b', - 'c', - 'd', - 'e', - 'nested-2', - 'deeply-nested' - ) + Path.pub('a', 'b', 'c', 'd', 'e', 'nested-2', 'deeply-nested') ), false ) }) it('moves private directories', async () => { - const fromPath = Path.directory('private', 'b', 'c') - const toPath = Path.directory('private', 'a', 'b', 'c', 'd', 'e') + const fromPath = Path.priv('b', 'c') + const toPath = Path.priv('a', 'b', 'c', 'd', 'e') - await fs.write(Path.combine(fromPath, Path.file('file')), 'utf8', '💃') - await fs.write( - Path.combine(fromPath, Path.file('nested', 'file')), - 'utf8', - '🧞' - ) + await fs.write(Path.combine(fromPath, ['file']), 'utf8', '💃') + await fs.write(Path.combine(fromPath, ['nested', 'file']), 'utf8', '🧞') + await fs.ensureDirectory(Path.combine(fromPath, ['nested-empty'])) await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-empty')) - ) - await fs.ensureDirectory( - Path.combine(fromPath, Path.directory('nested-2', 'deeply-nested')) + Path.combine(fromPath, ['nested-2', 'deeply-nested']) ) await fs.move(fromPath, toPath) - assert.equal( - await fs.read(Path.combine(toPath, Path.file('file')), 'utf8'), - '💃' - ) + assert.equal(await fs.read(Path.combine(toPath, ['file']), 'utf8'), '💃') assert.equal( - await fs.read(Path.combine(toPath, Path.file('nested', 'file')), 'utf8'), + await fs.read(Path.combine(toPath, ['nested', 'file']), 'utf8'), '🧞' ) - assert.equal( - await fs.exists(Path.combine(toPath, Path.directory('nested-empty'))), - true - ) + assert.equal(await fs.exists(Path.combine(toPath, ['nested-empty'])), true) assert.equal( - await fs.exists( - Path.combine(toPath, Path.directory('nested-2', 'deeply-nested')) - ), + await fs.exists(Path.combine(toPath, ['nested-2', 'deeply-nested'])), true ) assert.equal(await fs.exists(fromPath), false) - await fs.move(Path.directory('private', 'a'), Path.directory('private')) + await fs.move(Path.priv('a'), Path.priv()) assert.equal( - await fs.exists( - Path.directory('public', 'b', 'c', 'nested-2', 'deeply-nested') - ), + await fs.exists(Path.pub('b', 'c', 'nested-2', 'deeply-nested')), false ) - assert.equal(await fs.exists(Path.directory('public', 'a')), false) + assert.equal(await fs.exists(Path.pub('a')), false) assert.equal( await fs.exists( - Path.directory( - 'public', - 'a', - 'b', - 'c', - 'd', - 'e', - 'nested-2', - 'deeply-nested' - ) + Path.pub('a', 'b', 'c', 'd', 'e', 'nested-2', 'deeply-nested') ), false ) }) it('moves a public file to the private partition', async () => { - const fromPath = Path.file('public', 'a', 'b', 'file') - const toPath = Path.file('private', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.pub('a', 'b', 'file') + const toPath = Path.priv('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.move(fromPath, toPath) @@ -949,8 +817,8 @@ describe('File System Class', () => { }) it('moves a private file to the public partition', async () => { - const fromPath = Path.file('private', 'a', 'b', 'file') - const toPath = Path.file('public', 'a', 'b', 'c', 'd', 'file') + const fromPath = Path.priv('a', 'b', 'file') + const toPath = Path.pub('a', 'b', 'c', 'd', 'file') await fs.write(fromPath, 'utf8', '💃') await fs.move(fromPath, toPath) @@ -963,39 +831,39 @@ describe('File System Class', () => { // -------- it('renames public files', async () => { - await fs.write(Path.file('public', 'a'), 'bytes', new Uint8Array()) - await fs.rename(Path.file('public', 'a'), 'b') + await fs.write(Path.pub('a'), 'bytes', new Uint8Array()) + await fs.rename(Path.pub('a'), 'b') - assert.equal(await fs.exists(Path.file('public', 'a')), false) + assert.equal(await fs.exists(Path.pub('a')), false) - assert.equal(await fs.exists(Path.file('public', 'b')), true) + assert.equal(await fs.exists(Path.pub('b')), true) }) it('renames private files', async () => { - await fs.write(Path.file('private', 'a'), 'bytes', new Uint8Array()) - await fs.rename(Path.file('private', 'a'), 'b') + await fs.write(Path.priv('a'), 'bytes', new Uint8Array()) + await fs.rename(Path.priv('a'), 'b') - assert.equal(await fs.exists(Path.file('private', 'a')), false) + assert.equal(await fs.exists(Path.priv('a')), false) - assert.equal(await fs.exists(Path.file('private', 'b')), true) + assert.equal(await fs.exists(Path.priv('b')), true) }) it('renames public directories', async () => { - await fs.ensureDirectory(Path.directory('public', 'a')) - await fs.rename(Path.directory('public', 'a'), 'b') + await fs.ensureDirectory(Path.pub('a')) + await fs.rename(Path.pub('a'), 'b') - assert.equal(await fs.exists(Path.directory('public', 'a')), false) + assert.equal(await fs.exists(Path.pub('a')), false) - assert.equal(await fs.exists(Path.directory('public', 'b')), true) + assert.equal(await fs.exists(Path.pub('b')), true) }) it('renames private directories', async () => { - await fs.ensureDirectory(Path.directory('private', 'a')) - await fs.rename(Path.directory('private', 'a'), 'b') + await fs.ensureDirectory(Path.priv('a')) + await fs.rename(Path.priv('a'), 'b') - assert.equal(await fs.exists(Path.directory('private', 'a')), false) + assert.equal(await fs.exists(Path.priv('a')), false) - assert.equal(await fs.exists(Path.directory('private', 'b')), true) + assert.equal(await fs.exists(Path.priv('b')), true) }) // PUBLISHING @@ -1013,15 +881,11 @@ describe('File System Class', () => { .then(resolve, reject) }) - await fs.write(Path.file('private', 'a'), 'bytes', new Uint8Array()) - await fs.write(Path.file('private', 'b'), 'bytes', new Uint8Array()) - await fs.write(Path.file('private', 'c'), 'bytes', new Uint8Array()) + await fs.write(Path.priv('a'), 'bytes', new Uint8Array()) + await fs.write(Path.priv('b'), 'bytes', new Uint8Array()) + await fs.write(Path.priv('c'), 'bytes', new Uint8Array()) - const d = await fs.write( - Path.file('private', 'd'), - 'bytes', - new Uint8Array() - ) + const d = await fs.write(Path.priv('d'), 'bytes', new Uint8Array()) const result = await promise assert.equal(result.toString(), d.dataRoot.toString()) @@ -1030,26 +894,33 @@ describe('File System Class', () => { it("doesn't publish when asked not to do so", async () => { let published = false + fs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + _mounts = [ + await fs.createPrivateNode({ path: Path.root() }, { skipPublish: true }), + ] + fs.on('publish', () => { published = true }) - await fs.mkdir(Path.directory('private', 'dir'), { skipPublish: true }) - await fs.write(Path.file('public', 'file'), 'bytes', new Uint8Array(), { + await fs.mkdir(Path.priv('dir'), { skipPublish: true }) + await fs.write(Path.pub('file'), 'bytes', new Uint8Array(), { skipPublish: true, }) - await fs.cp(Path.file('public', 'file'), Path.file('private', 'file'), { + await fs.cp(Path.pub('file'), Path.priv('file'), { skipPublish: true, }) - await fs.mv( - Path.file('private', 'file'), - Path.file('private', 'dir', 'file'), - { skipPublish: true } - ) - await fs.rename(Path.file('private', 'dir', 'file'), 'renamed', { + await fs.mv(Path.priv('file'), Path.priv('dir', 'file'), { + skipPublish: true, + }) + await fs.rename(Path.priv('dir', 'file'), 'renamed', { skipPublish: true, }) - await fs.rm(Path.file('private', 'dir', 'renamed'), { skipPublish: true }) + await fs.rm(Path.priv('dir', 'renamed'), { skipPublish: true }) await new Promise((resolve) => setTimeout(resolve, fsOpts.settleTimeBeforePublish * 1.5) @@ -1072,7 +943,7 @@ describe('File System Class', () => { }) const mutationResult = await fs.write( - Path.file('private', 'file'), + Path.priv('file'), 'bytes', new Uint8Array() ) @@ -1086,27 +957,27 @@ describe('File System Class', () => { it('commits a transaction', async () => { await fs.transaction(async (t) => { - await t.write(Path.file('private', 'file'), 'utf8', '💃') + await t.write(Path.priv('file'), 'utf8', '💃') await t.write( - Path.file('public', 'file'), + Path.pub('file'), 'bytes', - await t.read(Path.file('private', 'file'), 'bytes') + await t.read(Path.priv('file'), 'bytes') ) }) - assert.equal(await fs.read(Path.file('public', 'file'), 'utf8'), '💃') + assert.equal(await fs.read(Path.pub('file'), 'utf8'), '💃') }) it("doesn't commit a transaction when an error occurs inside of the transaction", async () => { await fs .transaction(async (t) => { - await t.write(Path.file('private', 'file'), 'utf8', '💃') + await t.write(Path.priv('file'), 'utf8', '💃') throw new Error('Whoops') }) .catch((_error) => {}) try { - await fs.read(Path.file('private', 'file'), 'utf8') + await fs.read(Path.priv('file'), 'utf8') } catch (error) { assert(error) } @@ -1119,12 +990,335 @@ describe('File System Class', () => { onCommit: async (_modifications: Modification[]) => ({ commit: false }), }) - _mounts = await fs.mountPrivateNodes([{ path: Path.root() }]) + _mounts = [await fs.createPrivateNode({ path: Path.root() })] const result = await fs.transaction(async (t) => { - await t.write(Path.file('private', 'file'), 'utf8', '💃') + await t.write(Path.priv('file'), 'utf8', '💃') }) assert.equal(result, 'no-op') }) + + // SHARING + + it('can share a file using a transaction and receive it', async () => { + const keypair = await ExchangeKey.generate() + + const receiverFs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + await receiverFs.transaction(async (t) => { + await t.registerExchangeKey('device', keypair.publicKey) + }) + + const receiverDataRoot = await receiverFs.calculateDataRoot() + + await fs.transaction(async (t) => { + const path = Path.priv('fileToShare') + await t.assignIdentifier('did:test:1') + await t.write(path, 'utf8', '🔒') + await t.share(path, receiverDataRoot) + }) + + const sharerDataRoot = await fs.calculateDataRoot() + + let content = '' + + await receiverFs.transaction(async (t) => { + const ctx = await t.receive(sharerDataRoot, keypair) + + content = await ctx.read('utf8') + }) + + assert.equal(content, '🔒') + }) + + it('can share a file and receive it', async () => { + const keypair = await ExchangeKey.generate() + + const receiverFs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + // Register exchange key + const a = await receiverFs.registerExchangeKey('device', keypair.publicKey) + const receiverDataRoot = a.dataRoot + + // Assign sharer identifier & create share + const path = Path.priv('nested', 'level 2', 'fileToShare') + await fs.assignIdentifier('did:test:1') + await fs.write(path, 'utf8', '🔒') + const b = await fs.share(Path.priv('nested'), receiverDataRoot) + + // Receive share + const sharerDataRoot = b.dataRoot + const share = await receiverFs.receive(sharerDataRoot, keypair) + + const content = await share.read('utf8', ['level 2', 'fileToShare']) + + // Assert + assert.equal(content, '🔒') + }) + + it('can mount a share made to self', async () => { + const keypair = await ExchangeKey.generate() + + // Register exchange key + await fs.registerExchangeKey('device', keypair.publicKey) + + // Assign sharer identifier & create share + const path = Path.priv('nested', 'level 2', 'fileToShare') + await fs.assignIdentifier('did:test:2') + await fs.write(path, 'utf8', '🔒') + await fs.share(Path.priv('nested'), await fs.calculateDataRoot()) + + // Unmount root test node, otherwise we'd search from the root, + // instead of the new mount. + fs.unmountPrivateNode(Path.root()) + + // Mount node + await fs.mountPrivateNode({ + path: ['shared', 'item'], + exchangeKeyPair: keypair, + }) + + const content = await fs.read( + Path.priv('shared', 'item', 'level 2', 'fileToShare'), + 'utf8' + ) + + // Assert + assert.equal(content, '🔒') + }) + + it('can mount the root node shared to self', async () => { + const keypair = await ExchangeKey.generate() + + // Register exchange key + await fs.registerExchangeKey('device', keypair.publicKey) + + // Assign sharer identifier & create share + const path = Path.priv('nested', 'level 2', 'fileToShare') + await fs.assignIdentifier('did:test:3') + await fs.write(path, 'utf8', '🔒') + await fs.share(Path.priv(), await fs.calculateDataRoot()) + + // Unmount existing root node + fs.unmountPrivateNode(Path.root()) + + // Remount root node using exchange key pair + await fs.mountPrivateNode({ + path: Path.root(), + exchangeKeyPair: keypair, + }) + + // Check content + const content = await fs.read( + Path.priv('nested', 'level 2', 'fileToShare'), + 'utf8' + ) + + assert.equal(content, '🔒') + + // Test mutations + await fs.write(Path.priv('nested', 'level 2', 'fileToShare'), 'utf8', '🚀') + + const content2 = await fs.read( + Path.priv('nested', 'level 2', 'fileToShare'), + 'utf8' + ) + + assert.equal(content2, '🚀') + + await fs.write(Path.priv('dir', 'test'), 'utf8', '✌️') + + const content3 = await fs.read(Path.priv('dir', 'test'), 'utf8') + + assert.equal(content3, '✌️') + }) + + it('can create and load a private node using an exchange key', async () => { + const keypair = await ExchangeKey.generate() + + fs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + try { + await fs.createPrivateNode({ + path: Path.root(), + exchangeKeyPair: keypair, + }) + } catch (error) { + assert(error instanceof Error) + } + + await fs.assignIdentifier('did:test:4') + await fs.registerExchangeKey('device', keypair.publicKey) + + _mounts = [ + await fs.createPrivateNode({ + path: Path.root(), + exchangeKeyPair: keypair, + }), + ] + + await fs.write(Path.priv('nested', 'level 2', 'fileToShare'), 'utf8', '🚀') + + const content2 = await fs.read( + Path.priv('nested', 'level 2', 'fileToShare'), + 'utf8' + ) + + assert.equal(content2, '🚀') + + const { dataRoot } = await fs.write(Path.priv('dir', 'test'), 'utf8', '✌️') + + // Try to load a new instance + const fsInstance = await FileSystem.fromCID(dataRoot, { + blockstore, + ...fsOpts, + }) + + await fsInstance.mountPrivateNode({ + path: Path.root(), + exchangeKeyPair: keypair, + }) + + const content = await fsInstance.read(Path.priv('dir', 'test'), 'utf8') + + assert.equal(content, '✌️') + }) + + it('can share multiple items and still load the older ones', async () => { + const keypairA = await ExchangeKey.generate() + const keypairB = await ExchangeKey.generate() + + const receiverFs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + // Register exchange key + await receiverFs.registerExchangeKey('device-a', keypairA.publicKey) + await receiverFs.registerExchangeKey('device-b', keypairB.publicKey) + const receiverDataRoot = await receiverFs.calculateDataRoot() + + // Assign sharer identifier & create share + await fs.assignIdentifier('did:test:5') + + await fs.write(Path.priv('file 1'), 'utf8', '🔐 1') + await fs.write(Path.priv('file 2'), 'utf8', '🔒 2') + + const { shareId } = await fs.share(Path.priv('file 1'), receiverDataRoot) + + await fs.share(Path.priv('file 2'), receiverDataRoot) + + // Receive shares + const sharerDataRoot = await fs.calculateDataRoot() + + const content1 = await receiverFs + .receive(sharerDataRoot, keypairA, { shareId }) + .then(async (share) => await share.read('utf8')) + + const content2 = await receiverFs + .receive(sharerDataRoot, keypairB) + .then(async (share) => await share.read('utf8')) + + // Assert + assert.equal(content1, '🔐 1') + assert.equal(content2, '🔒 2') + }) + + it('can create multiple private nodes with an exchange key and still mount the older one', async () => { + const keypair = await ExchangeKey.generate() + + fs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + await fs.assignIdentifier('did:test:6') + await fs.registerExchangeKey('device', keypair.publicKey) + + const first = await fs.createPrivateNode({ + path: ['first'], + exchangeKeyPair: keypair, + }) + + const _second = await fs.createPrivateNode({ + path: ['second'], + exchangeKeyPair: keypair, + }) + + await fs.write(Path.priv('first', 'nested', 'file'), 'utf8', '🔑') + + await fs.write(Path.priv('second', 'file'), 'utf8', '🔒') + + const fsInstance = await FileSystem.fromCID(await fs.calculateDataRoot(), { + blockstore, + }) + + await fsInstance.mountPrivateNode({ + path: ['first'], + exchangeKeyPair: keypair, + shareId: first.shareId, + }) + + await fsInstance.mountPrivateNode({ + path: ['second'], + exchangeKeyPair: keypair, + }) + + const contents = await fsInstance.read( + Path.priv('first', 'nested', 'file'), + 'utf8' + ) + + assert.equal(contents, '🔑') + + const contents2 = await fsInstance.read(Path.priv('second', 'file'), 'utf8') + + assert.equal(contents2, '🔒') + }) + + it('can create a private node using an exchange key and then later mount it using the capsule key', async () => { + const keypair = await ExchangeKey.generate() + + fs = await FileSystem.create({ + blockstore, + ...fsOpts, + }) + + await fs.assignIdentifier('did:test:7') + await fs.registerExchangeKey('device', keypair.publicKey) + + await fs.createPrivateNode({ + path: Path.root(), + exchangeKeyPair: keypair, + }) + + const { capsuleKey, dataRoot } = await fs.write( + Path.priv('file'), + 'utf8', + '👀' + ) + + const fsInstance = await FileSystem.fromCID(dataRoot, { + blockstore, + }) + + await fsInstance.mountPrivateNode({ + path: Path.root(), + capsuleKey, + }) + + const contents = await fs.read(Path.priv('file'), 'utf8') + + assert.equal(contents, '👀') + }) }) diff --git a/packages/nest/test/helpers/index.ts b/packages/nest/test/helpers/index.ts index ef590d5..6ef62ff 100644 --- a/packages/nest/test/helpers/index.ts +++ b/packages/nest/test/helpers/index.ts @@ -13,32 +13,33 @@ import * as Path from '../../src/path.js' // PATHS +/** + * + * @param partition + */ export function arbitraryDirectoryPath

( partition: P -): fc.Arbitrary>> { +): fc.Arbitrary> { return fc .array(arbitraryPathSegment(), { minLength: 1, maxLength: 8 }) - .map((array) => { - const path: Path.Directory> = { - directory: [partition, ...array] as any, - } - return path - }) + .map((array) => [partition, ...array] as Path.PartitionedNonEmpty

) } +/** + * + * @param partition + */ export function arbitraryFilePath

( partition: P -): fc.Arbitrary>> { +): fc.Arbitrary> { return fc .array(arbitraryPathSegment(), { minLength: 1, maxLength: 8 }) - .map((array) => { - const path: Path.File> = { - file: [partition, ...array] as any, - } - return path - }) + .map((array) => [partition, ...array] as Path.PartitionedNonEmpty

) } +/** + * + */ export function arbitraryPathSegment(): fc.Arbitrary { return fc.oneof( fc.webSegment().filter((segment) => segment.length > 0), @@ -48,10 +49,17 @@ export function arbitraryPathSegment(): fc.Arbitrary { // UNIX +/** + * + * @param opts + * @param opts.blockstore + * @param fs + * @param path + */ export async function assertUnixFsDirectory( opts: { blockstore: Blockstore }, fs: FileSystem, - path: Path.Directory> + path: Path.Partitioned ): Promise { const dataRoot = await fs.calculateDataRoot() @@ -69,10 +77,18 @@ export async function assertUnixFsDirectory( assert.equal(entry.type, 'directory') } +/** + * + * @param opts + * @param opts.blockstore + * @param fs + * @param path + * @param bytes + */ export async function assertUnixFsFile( opts: { blockstore: Blockstore }, fs: FileSystem, - path: Path.File>, + path: Path.Partitioned, bytes: Uint8Array ): Promise { const dataRoot = await fs.calculateDataRoot() @@ -94,10 +110,17 @@ export async function assertUnixFsFile( assert.equal(Uint8Arrays.equals(unixBytes, bytes), true) } +/** + * + * @param opts + * @param opts.blockstore + * @param fs + * @param path + */ export async function assertUnixNodeRemoval( opts: { blockstore: Blockstore }, fs: FileSystem, - path: Path.Distinctive> + path: Path.Partitioned ): Promise { const dataRoot = await fs.calculateDataRoot() diff --git a/packages/nest/test/path.test.ts b/packages/nest/test/path.test.ts deleted file mode 100644 index 7b33336..0000000 --- a/packages/nest/test/path.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { strict as assert } from 'assert' -import * as fc from 'fast-check' - -import type { DirectoryPath, FilePath } from '../src/path.js' -import * as Path from '../src/path.js' -import { RootBranch } from '../src/path.js' - -describe('Path functions', () => { - // CREATION - - it('creates directory paths', () => { - fc.assert( - fc.property(fc.array(fc.hexaString()), (data) => { - assert.deepEqual(Path.directory(...data), { directory: data }) - }) - ) - - assert.throws(() => Path.directory('/')) - - // Type testing - const _a: Path.Directory> = - Path.directory('private') - const _b: Path.Directory> = - Path.directory('public', 'a') - const _c: Path.Directory = Path.directory( - 'private', - 'a', - 'b' - ) - }) - - it('creates file paths', () => { - fc.assert( - fc.property(fc.array(fc.hexaString()), (data) => { - assert.deepEqual(Path.file(...data), { file: data }) - }) - ) - - assert.throws(() => Path.file('/')) - - // Type testing - const _a: Path.File> = Path.file( - 'private', - 'a' - ) - const _b: Path.File = Path.file('private', 'a', 'b') - }) - - it('creates directory paths with fromKind', () => { - fc.assert( - fc.property(fc.array(fc.hexaString()), (data) => { - assert.deepEqual(Path.fromKind(Path.Kind.Directory, ...data), { - directory: data, - }) - }) - ) - - // Type testing - const _a: Path.Directory> = Path.fromKind( - Path.Kind.Directory, - 'private' - ) - const _b: Path.Directory> = - Path.fromKind(Path.Kind.Directory, 'public', 'a') - const _c: Path.Directory = Path.fromKind( - Path.Kind.Directory, - 'private', - 'a', - 'b' - ) - }) - - it('creates file paths with fromKind', () => { - fc.assert( - fc.property(fc.array(fc.hexaString()), (data) => { - assert.deepEqual(Path.fromKind(Path.Kind.File, ...data), { file: data }) - }) - ) - - // Type testing - const _a: Path.File> = Path.fromKind( - Path.Kind.File, - 'private', - 'a' - ) - const _b: Path.File = Path.fromKind( - Path.Kind.File, - 'private', - 'a', - 'b' - ) - }) - - // POSIX - - it('creates a path from a POSIX formatted string', () => { - assert.deepEqual(Path.fromPosix('foo/bar/'), { directory: ['foo', 'bar'] }) - - assert.deepEqual(Path.fromPosix('/foo/bar/'), { directory: ['foo', 'bar'] }) - - assert.deepEqual(Path.fromPosix('/'), { directory: [] }) - - assert.deepEqual(Path.fromPosix('foo/bar'), { file: ['foo', 'bar'] }) - - assert.deepEqual(Path.fromPosix('/foo/bar'), { file: ['foo', 'bar'] }) - }) - - it('converts a path to the POSIX format', () => { - assert.equal(Path.toPosix({ directory: ['foo', 'bar'] }), 'foo/bar/') - - assert.equal(Path.toPosix({ directory: [] }), '') - - assert.equal(Path.toPosix({ file: ['foo', 'bar'] }), 'foo/bar') - }) - - // 🛠 - - it('can create app-data paths', () => { - const appInfo = { - name: 'Tests', - creator: 'WNFS WG', - } - - const root: DirectoryPath> = - Path.appData('private', appInfo) - - assert.deepEqual(root, { - directory: [RootBranch.Private, 'Apps', appInfo.creator, appInfo.name], - }) - - const dir: DirectoryPath> = - Path.appData('private', appInfo, Path.directory('a')) - - assert.deepEqual(dir, { - directory: [ - RootBranch.Private, - 'Apps', - appInfo.creator, - appInfo.name, - 'a', - ], - }) - - const file: FilePath> = Path.appData( - 'public', - appInfo, - Path.file('a') - ) - - assert.deepEqual(file, { - file: [RootBranch.Public, 'Apps', appInfo.creator, appInfo.name, 'a'], - }) - }) - - it('can be combined', () => { - const dir: DirectoryPath = Path.combine( - Path.directory('a'), - Path.directory('b') - ) - - assert.deepEqual(dir, { directory: ['a', 'b'] }) - - const file: FilePath = Path.combine( - Path.directory('a'), - Path.file('b') - ) - - assert.deepEqual(file, { file: ['a', 'b'] }) - - // Type testing - const _a: DirectoryPath> = - Path.combine(Path.directory('private'), Path.directory('a')) - - const _aa: FilePath> = Path.combine( - Path.directory('public'), - Path.file('a') - ) - - const _b: DirectoryPath> = Path.combine( - Path.directory('private'), - Path.directory() - ) - - const _bb: FilePath> = Path.combine( - Path.directory('public'), - Path.file() - ) - - const _c: DirectoryPath> = - Path.combine(Path.directory('private'), Path.directory('a')) - - const _cc: FilePath> = Path.combine( - Path.directory('public'), - Path.file('a') - ) - }) - - it('supports isOnRootBranch', () => { - assert.equal( - Path.isOnRootBranch( - RootBranch.Private, - Path.directory(RootBranch.Private, 'a') - ), - true - ) - - assert.equal( - Path.isOnRootBranch( - RootBranch.Public, - Path.directory(RootBranch.Private, 'a') - ), - false - ) - }) - - it('supports isDirectory', () => { - assert.equal(Path.isDirectory(Path.directory(RootBranch.Private)), true) - - assert.equal(Path.isDirectory(Path.file('foo')), false) - }) - - it('supports isFile', () => { - assert.equal(Path.isFile(Path.file('foo')), true) - - assert.equal(Path.isFile(Path.directory(RootBranch.Private)), false) - }) - - it('supports isRootDirectory', () => { - assert.equal(Path.isRootDirectory(Path.root()), true) - - assert.equal(Path.isRootDirectory(Path.directory()), true) - - assert.equal( - Path.isRootDirectory(Path.directory(RootBranch.Private)), - false - ) - }) - - it('supports isSamePartition', () => { - assert.equal( - Path.isSamePartition( - Path.directory(RootBranch.Private), - Path.directory(RootBranch.Private) - ), - true - ) - - assert.equal( - Path.isSamePartition( - Path.directory(RootBranch.Private), - Path.directory(RootBranch.Public) - ), - false - ) - }) - - it('supports isSameKind', () => { - assert.equal(Path.isSameKind(Path.directory(), Path.file()), false) - - assert.equal(Path.isSameKind(Path.file(), Path.directory()), false) - - assert.equal(Path.isSameKind(Path.directory(), Path.directory()), true) - - assert.equal(Path.isSameKind(Path.file(), Path.file()), true) - }) - - it('has kind', () => { - assert.equal(Path.kind(Path.directory()), Path.Kind.Directory) - - assert.equal(Path.kind(Path.file()), Path.Kind.File) - }) - - it('supports map', () => { - assert.deepEqual( - Path.map((p) => [...p, 'bar'], Path.directory('foo')), // eslint-disable-line unicorn/no-array-method-this-argument - { directory: ['foo', 'bar'] } - ) - - assert.deepEqual( - Path.map((p) => [...p, 'bar'], Path.file('foo')), // eslint-disable-line unicorn/no-array-method-this-argument - { file: ['foo', 'bar'] } - ) - }) - - it('supports parent', () => { - assert.deepEqual(Path.parent(Path.directory('foo')), Path.root()) - - assert.deepEqual(Path.parent(Path.file('foo')), Path.root()) - - assert.equal(Path.parent(Path.root()), undefined) - - // Type testing - const _a: DirectoryPath> = - Path.parent({ - directory: ['private', 'a', 'b'], - }) - - const _a_: DirectoryPath = Path.parent({ - directory: ['random', 'a', 'b'], - }) - - const _b: DirectoryPath> = Path.parent({ - directory: ['private', 'a'], - }) - - const _b_: DirectoryPath = Path.parent({ - directory: ['random', 'a'], - }) - - const _c: DirectoryPath = Path.parent({ - directory: ['private'], - }) - - const _c_: DirectoryPath = Path.parent({ - directory: ['random'], - }) - - // const _x: undefined = Path.parent({ - // directory: [], - // }) - }) - - it('supports removePartition', () => { - assert.deepEqual(Path.removePartition(Path.directory('foo')), { - directory: [], - }) - - assert.deepEqual( - Path.removePartition(Path.directory('foo', 'bar')), - Path.directory('bar') - ) - }) - - it('supports replaceTerminus', () => { - assert.deepEqual( - Path.replaceTerminus(Path.file('private', 'a', 'b'), 'c'), - Path.file('private', 'a', 'c') - ) - - // Type testing - const _a: DirectoryPath> = - Path.replaceTerminus( - { - directory: ['private', 'a'], - }, - 'b' - ) - - const _b: FilePath> = - Path.replaceTerminus( - { - file: ['private', 'a'], - }, - 'b' - ) - - const _c: DirectoryPath = Path.replaceTerminus( - { - directory: ['a'], - }, - 'b' - ) - - const _d: FilePath = Path.replaceTerminus( - { - file: ['a'], - }, - 'b' - ) - }) - - it('correctly unwraps', () => { - assert.deepEqual(Path.unwrap(Path.directory('foo')), ['foo']) - - assert.deepEqual(Path.unwrap(Path.file('foo')), ['foo']) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d22e1f2..a89ca5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3309,7 +3309,7 @@ packages: resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==} engines: {node: '>=10.13.0'} dependencies: - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 tapable: 2.2.1 dev: true @@ -7253,7 +7253,7 @@ packages: engines: {node: '>=10.13.0'} dependencies: glob-to-regexp: 0.4.1 - graceful-fs: 4.2.10 + graceful-fs: 4.2.11 dev: true /webidl-conversions@3.0.1: