// A drive held in a permissioned space. The space side never touches the // public repo: it reads through the injected reader and writes through the // injected space commit, so a private drive stays out of the public firehose. import { describe, expect, it } from 'vitest'; import { createDriveBrowser } from '../src/browser.js'; import { createDriveWriter } from '../src/writer.js'; const DID = 'did:plc:drivespace'; const SPACE = `at://${DID}/space/com.example.team/main`; const CHUNK = 32; function harness({ withSpace = true } = {}) { /** @type {Map} keyed space#collection#rkey */ const spaceRecords = new Map(); /** @type {Map} */ const blobData = new Map(); /** @type {Array<{space: string, writes: any[]}>} */ const spaceCommits = []; /** @type {string[]} */ const repoWrites = []; /** @type {Array<{cid: string, uri: string}>} */ const links = []; /** @type {string[]} */ const unlinked = []; const actorStorage = /** @type {any} */ ({ getDid: async () => DID, getRecord: async () => null, listRecords: async () => ({ records: [], cursor: null }), putBlock: async () => {}, putRecord: async (/** @type {string} */ uri) => { repoWrites.push(uri); }, putBlob: async () => {}, linkBlobToRecord: async ( /** @type {string} */ cid, /** @type {string} */ uri, ) => { links.push({ cid, uri }); }, deleteRecord: async () => {}, unlinkBlobsFromRecord: async (/** @type {string} */ uri) => { unlinked.push(uri); }, }); const blobs = /** @type {any} */ ({ put: async ( /** @type {string} */ _did, /** @type {string} */ cid, /** @type {Uint8Array} */ data, ) => { blobData.set(cid, data); }, get: async () => null, }); const spaceReader = { listRecords: async ( /** @type {string} */ space, /** @type {string} */ collection, ) => ({ records: [...spaceRecords.entries()] .filter(([key]) => key.startsWith(`${space}#${collection}#`)) .map(([key, row]) => ({ rkey: key.split('#')[2], cid: row.cid, value: row.value, })), }), getRecord: async ( /** @type {string} */ space, /** @type {string} */ rkey, ) => spaceRecords.get(`${space}#dev.pdsjs.drive.file#${rkey}`) ?? null, getBlob: async (/** @type {string} */ cid) => blobData.get(cid) ?? null, }; /** Stands in for applyWrites: records the commit and keeps the value. */ const spaceWrite = async ( /** @type {string} */ space, /** @type {any[]} */ writes, ) => { spaceCommits.push({ space, writes }); const results = []; for (const write of writes) { const key = `${space}#${write.collection}#${write.rkey}`; if (write.action === 'delete') { if (!spaceRecords.has(key)) throw new Error('RecordNotFound'); spaceRecords.delete(key); results.push({ cid: null }); continue; } const cid = `bafyspace${spaceRecords.size}`; spaceRecords.set(key, { cid, value: write.record }); results.push({ cid }); } return { rev: '3rev', results }; }; const ports = { actorStorage, blobs, getDid: async () => DID }; return { browser: createDriveBrowser({ ...ports, spaceReader: withSpace ? spaceReader : undefined, }), writer: createDriveWriter({ ...ports, createCommit: async () => ({}), chunkSize: CHUNK, spaceReader: withSpace ? spaceReader : undefined, spaceWrite: withSpace ? spaceWrite : undefined, }), spaceCommits, spaceRecords, repoWrites, links, unlinked, }; } /** @param {Uint8Array|string} data */ function streamOf(data) { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; return new ReadableStream({ start(controller) { controller.enqueue(bytes); controller.close(); }, }); } describe('a drive in a space', () => { it('commits into the space and never into the public repo', async () => { const { writer, spaceCommits, repoWrites } = harness(); const row = await writer.createFile({ name: 'secret.txt', folder: null, mimeType: 'text/plain', body: streamOf('for the team'), space: SPACE, }); expect(row.uri).toBe(`${SPACE}/${DID}/dev.pdsjs.drive.file/${row.rkey}`); expect(repoWrites).toEqual([]); expect(spaceCommits).toHaveLength(1); expect(spaceCommits[0].space).toBe(SPACE); expect(spaceCommits[0].writes[0]).toMatchObject({ collection: 'dev.pdsjs.drive.file', rkey: row.rkey, }); }); it('carries blob refs the space write path can link', async () => { const { writer, spaceCommits } = harness(); await writer.createFile({ name: 'a.txt', folder: null, mimeType: 'text/plain', body: streamOf('hello'), space: SPACE, }); // applyWrites finds a part through findBlobRefs, which reads `$link` and // not a CID instance. A part written any other way would go unlinked and // be reaped out from under the record. const record = spaceCommits[0].writes[0].record; expect(typeof record.parts[0].ref.$link).toBe('string'); }); it('links every part to the space record, so the reap and getBlob see it', async () => { const { writer, links } = harness(); const data = new Uint8Array(CHUNK * 2 + 1).fill(3); const row = await writer.createFile({ name: 'big.bin', folder: null, mimeType: 'application/octet-stream', body: streamOf(data), space: SPACE, }); // applyWrites advances the set hash but indexes no blobs: only the space // write route does that. An unlinked part is reaped, and until then the // public getBlob endpoint serves it, because that endpoint only withholds // a blob whose every link is a space record. expect(links).toHaveLength(row.partCount); for (const link of links) { expect(link.uri).toBe(`${SPACE}/${DID}/dev.pdsjs.drive.file/${row.rkey}`); } }); it('makes folders inside the space too', async () => { const { writer, browser } = harness(); const folder = await writer.createFolder({ name: 'plans', parent: null, space: SPACE, }); const row = await writer.createFile({ name: 'notes.txt', folder: folder.rkey, mimeType: 'text/plain', body: streamOf('team notes'), space: SPACE, }); const listing = await browser.listFolder(folder.rkey, SPACE); expect(listing?.space).toBe(SPACE); expect(listing?.path).toBe('plans'); expect(listing?.files.map((file) => file.name)).toEqual(['notes.txt']); const file = await browser.readFile(row.rkey, SPACE); expect(new TextDecoder().decode(file?.bytes)).toBe('team notes'); }); it('splits a large space file the same way', async () => { const { writer, browser } = harness(); const data = new Uint8Array(CHUNK * 2 + 3).fill(7); const row = await writer.createFile({ name: 'big.bin', folder: null, mimeType: 'application/octet-stream', body: streamOf(data), space: SPACE, }); expect(row.partCount).toBe(3); expect((await browser.readFile(row.rkey, SPACE))?.bytes).toEqual(data); }); it('serves a byte range from a space file', async () => { const { writer, browser } = harness(); const row = await writer.createFile({ name: 'ranged.txt', folder: null, mimeType: 'text/plain', body: streamOf('0123456789'), space: SPACE, }); const part = await browser.readRange(row.rkey, 2, 5, SPACE); expect(new TextDecoder().decode(part?.bytes)).toBe('2345'); }); it('keeps the space drive apart from the public one', async () => { const { writer, browser } = harness(); await writer.createFile({ name: 'private.txt', folder: null, mimeType: 'text/plain', body: streamOf('x'), space: SPACE, }); // The public listing reads the repo, which this write never touched. expect((await browser.listFolder(''))?.files).toEqual([]); expect((await browser.listFolder('', SPACE))?.files).toHaveLength(1); }); it('trashes through the space commit, keeping the parts linked', async () => { const { writer, browser, spaceRecords, unlinked } = harness(); const row = await writer.createFile({ name: 'gone.txt', folder: null, mimeType: 'text/plain', body: streamOf('x'), space: SPACE, }); const linkedOnUpload = unlinked.length; expect(await writer.deleteFile(row.rkey, SPACE)).toBe(true); // The record stays, carrying deletedAt, and no part is unlinked: a space // drive comes back out of the trash the same way the repo one does. expect(spaceRecords.size).toBe(1); expect(unlinked.length).toBe(linkedOnUpload); expect((await browser.listFolder('', SPACE))?.files).toEqual([]); expect((await browser.listTrash(SPACE))?.items.map((i) => i.name)).toEqual([ 'gone.txt', ]); // A record that is not there reads as a miss, not an error. expect(await writer.deleteFile('nosuch', SPACE)).toBe(false); }); it('frees the bytes only when the trash is emptied', async () => { const { writer, browser, spaceRecords, unlinked } = harness(); const row = await writer.createFile({ name: 'gone.txt', folder: null, mimeType: 'text/plain', body: streamOf('x'), space: SPACE, }); await writer.deleteFile(row.rkey, SPACE); expect(await writer.purge(row.rkey, SPACE)).toBe(1); expect(spaceRecords.size).toBe(0); expect(unlinked).toContain( `${SPACE}/${DID}/dev.pdsjs.drive.file/${row.rkey}`, ); expect((await browser.listTrash(SPACE))?.items).toEqual([]); }); it('refuses a space when the server composes no space write path', async () => { const { writer } = harness({ withSpace: false }); await expect( writer.createFile({ name: 'a.txt', folder: null, mimeType: 'text/plain', body: streamOf('x'), space: SPACE, }), ).rejects.toThrow(/cannot write to a space/); }); it('reports no listing for a space it cannot read', async () => { const { browser } = harness({ withSpace: false }); expect(await browser.listFolder('', SPACE)).toBe(null); expect(await browser.readFile('any', SPACE)).toBe(null); }); });