~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
318 lines · 10.3 KB Raw History
// 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<string, {cid: string, value: any}>} keyed space#collection#rkey */
  const spaceRecords = new Map();
  /** @type {Map<string, Uint8Array>} */
  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);
  });
});