feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
Some checks failed
Test / test (push) Has been cancelled

M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).

@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
  Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
  + per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
  (> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
  / shadeFilesReadStreamId correlation. Server-side TransformStream
  bridges accept inbound transfers immediately (engine rejects chunks
  that arrive before accept) and park the readable for the matching
  RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
  uploadDirectory()/downloadDirectory() with bounded concurrency pool
  (default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
  byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
  policy (traversal + percent-decode hardening), fingerprint gate
  (required/optional/reject), pluggable Ed25519 sig verification with
  ±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
  useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
  entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.

Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
  u16 to u32. The previous u16 silently truncated payloads above
  64 KiB — a hard correctness ceiling that blocked inline file ops
  up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
  only. Cross-platform Kotlin port (android/shade-android) updated to
  match; test-vectors/wire-format.json regenerated.

Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
  Concurrent decryptions of the same peer raced ratchet state
  (manifested as sporadic "Failed to decrypt — wrong key or tampered
  data" under load — surfaced once concurrent uploadDirectory pumped
  many writes in flight). Encrypt was already serialized via
  Shade.send's encryptChains; decrypt is now serialized at the
  manager layer too.

@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
  application-level key/value pairs that round-trip verbatim through
  stream-init plaintext. Used by @shade/files for write/read
  correlation; available to any consumer.

@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
  BackgroundTasks.setHook(name, fn) for runtime hook registration.

Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
  shade-sdk streams-bridge + shade-widgets transfer hooks were
  uncommitted prior to this session. Including them keeps the
  workspace consistent at 0.3.0 since @shade/files depends on them.

Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
  3× stable). Coverage spans unit (inline-threshold + concurrency),
  integration (read-write inline + streams up to 1 MiB, walk +
  upload/download directory, custom-op, metrics, SDK namespace
  end-to-end), and security (tampered-envelope sig verification,
  replay window, fingerprint gate, rate-limit + quota).

Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
  topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
  server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 14:00:01 +02:00
parent 7e0f7320a9
commit fa770d3063
198 changed files with 20412 additions and 256 deletions

View File

@@ -0,0 +1,135 @@
import { describe, test, expect } from 'bun:test';
import {
canonicalRpcBytes,
canonicalJsonStringify,
hashArgs,
bytesToHex,
bytesToBase64,
base64ToBytes,
} from '../../src/index.js';
describe('canonicalJsonStringify', () => {
test('sorts object keys', () => {
expect(canonicalJsonStringify({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
});
test('skips undefined values', () => {
expect(canonicalJsonStringify({ a: 1, b: undefined, c: 2 })).toBe('{"a":1,"c":2}');
});
test('preserves array order', () => {
expect(canonicalJsonStringify([3, 1, 2])).toBe('[3,1,2]');
});
test('recursive sorting', () => {
const a = canonicalJsonStringify({ outer: { y: 1, x: 2 } });
const b = canonicalJsonStringify({ outer: { x: 2, y: 1 } });
expect(a).toBe(b);
expect(a).toBe('{"outer":{"x":2,"y":1}}');
});
test('handles primitives', () => {
expect(canonicalJsonStringify(null)).toBe('null');
expect(canonicalJsonStringify(true)).toBe('true');
expect(canonicalJsonStringify(42)).toBe('42');
expect(canonicalJsonStringify('hi')).toBe('"hi"');
});
test('different argument orders hash identically', () => {
const h1 = hashArgs({ b: 1, a: 2 });
const h2 = hashArgs({ a: 2, b: 1 });
expect(bytesToHex(h1)).toBe(bytesToHex(h2));
});
test('different values hash differently', () => {
expect(bytesToHex(hashArgs({ a: 1 }))).not.toBe(bytesToHex(hashArgs({ a: 2 })));
});
});
describe('canonicalRpcBytes', () => {
test('deterministic for same input (known vector)', () => {
const args = {
address: 'alice',
signedAt: 1730000000000,
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32).fill(0xab),
};
const a = canonicalRpcBytes(args);
const b = canonicalRpcBytes(args);
expect(a).toEqual(b);
});
test('changes when address differs', () => {
const base = {
signedAt: 1,
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32),
};
const a = canonicalRpcBytes({ ...base, address: 'alice' });
const b = canonicalRpcBytes({ ...base, address: 'bob' });
expect(a).not.toEqual(b);
});
test('changes when signedAt differs', () => {
const base = {
address: 'alice',
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32),
};
expect(canonicalRpcBytes({ ...base, signedAt: 1 })).not.toEqual(
canonicalRpcBytes({ ...base, signedAt: 2 }),
);
});
test('changes when kind differs', () => {
const base = {
address: 'alice',
signedAt: 1,
id: 'AbCdEfGhIjKlMnOpQrStUv',
argsHash: new Uint8Array(32),
};
expect(canonicalRpcBytes({ ...base, kind: 'shade.fs.list/v1' })).not.toEqual(
canonicalRpcBytes({ ...base, kind: 'shade.fs.stat/v1' }),
);
});
test('changes when argsHash differs', () => {
const base = {
address: 'alice',
signedAt: 1,
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
};
expect(
canonicalRpcBytes({ ...base, argsHash: new Uint8Array(32) }),
).not.toEqual(
canonicalRpcBytes({ ...base, argsHash: new Uint8Array(32).fill(1) }),
);
});
});
describe('hashArgs', () => {
test('produces 32-byte digest', () => {
expect(hashArgs({ a: 1 }).length).toBe(32);
});
test('null input is fine', () => {
expect(hashArgs(null).length).toBe(32);
});
});
describe('bytesToHex / base64', () => {
test('hex roundtrip', () => {
const b = new Uint8Array([0x01, 0xab, 0xff, 0x00]);
expect(bytesToHex(b)).toBe('01abff00');
});
test('base64 roundtrip', () => {
const b = new Uint8Array([1, 2, 3, 4]);
const enc = bytesToBase64(b);
expect(base64ToBytes(enc)).toEqual(b);
});
});

View File

@@ -0,0 +1,90 @@
import { describe, test, expect } from 'bun:test';
import { runWithConcurrency } from '../../src/client/concurrency.js';
async function* range(n: number): AsyncIterable<number> {
for (let i = 0; i < n; i++) yield i;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('runWithConcurrency', () => {
test('runs all items', async () => {
const seen: number[] = [];
await runWithConcurrency(range(10), async (i) => {
seen.push(i);
}, { concurrency: 4 });
expect(seen.sort((a, b) => a - b)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
});
test('respects concurrency cap (never exceeds N inflight)', async () => {
let inflight = 0;
let peak = 0;
await runWithConcurrency(range(50), async () => {
inflight++;
peak = Math.max(peak, inflight);
await delay(5);
inflight--;
}, { concurrency: 4 });
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThanOrEqual(2);
});
test('throws on first error by default (fail-fast)', async () => {
let processed = 0;
await expect(
runWithConcurrency(range(20), async (i) => {
await delay(1);
if (i === 3) throw new Error('boom');
processed++;
}, { concurrency: 2 }),
).rejects.toThrow('boom');
// We don't process all 20; bounded by fail-fast.
expect(processed).toBeLessThan(20);
});
test('continueOnError reports each + drains', async () => {
const errors: number[] = [];
let processed = 0;
await runWithConcurrency(range(10), async (i) => {
await delay(1);
if (i % 3 === 0) throw new Error(`bad-${i}`);
processed++;
}, {
concurrency: 3,
continueOnError: true,
onError: (item) => errors.push(item),
});
expect(processed).toBe(6); // i = 1,2,4,5,7,8
expect(errors.sort((a, b) => a - b)).toEqual([0, 3, 6, 9]);
});
test('aborts via signal', async () => {
const ctrl = new AbortController();
let processed = 0;
setTimeout(() => ctrl.abort(), 20);
await expect(
runWithConcurrency(range(100), async () => {
await delay(5);
processed++;
}, { concurrency: 4, signal: ctrl.signal }),
).rejects.toThrow();
expect(processed).toBeLessThan(100);
});
test('concurrency=1 is sequential', async () => {
const order: number[] = [];
await runWithConcurrency(range(5), async (i) => {
order.push(i);
await delay(1);
}, { concurrency: 1 });
expect(order).toEqual([0, 1, 2, 3, 4]);
});
test('throws on concurrency < 1', () => {
expect(() =>
runWithConcurrency(range(0), async () => undefined, { concurrency: 0 }),
).toThrow('concurrency must be ≥ 1');
});
});

View File

@@ -0,0 +1,64 @@
import { describe, test, expect } from 'bun:test';
import * as fc from 'fast-check';
import {
generateRequestId,
generateIdempotencyKey,
base64UrlEncode,
base64UrlDecode,
RequestIdSchema,
} from '../../src/index.js';
describe('generateRequestId', () => {
test('produces 22-char base64url string', () => {
const id = generateRequestId();
expect(id.length).toBe(22);
expect(RequestIdSchema.safeParse(id).success).toBe(true);
});
test('1e5 generated IDs are all unique', () => {
const seen = new Set<string>();
for (let i = 0; i < 100_000; i++) {
const id = generateRequestId();
expect(seen.has(id)).toBe(false);
seen.add(id);
}
expect(seen.size).toBe(100_000);
});
test('generateIdempotencyKey returns the same shape', () => {
expect(generateIdempotencyKey().length).toBe(22);
});
});
describe('base64url encode/decode', () => {
test('roundtrip arbitrary bytes (property-based)', () => {
fc.assert(
fc.property(fc.uint8Array({ minLength: 0, maxLength: 64 }), (bytes) => {
const decoded = base64UrlDecode(base64UrlEncode(bytes));
expect(decoded).toEqual(bytes);
}),
{ numRuns: 500 },
);
});
test('produces URL-safe alphabet only', () => {
fc.assert(
fc.property(fc.uint8Array({ minLength: 1, maxLength: 64 }), (bytes) => {
const enc = base64UrlEncode(bytes);
expect(enc).not.toMatch(/[+/=]/);
}),
{ numRuns: 200 },
);
});
test('handles empty input', () => {
expect(base64UrlEncode(new Uint8Array(0))).toBe('');
expect(base64UrlDecode('')).toEqual(new Uint8Array(0));
});
test('decodes inputs without padding correctly', () => {
expect(base64UrlDecode('YQ')).toEqual(new Uint8Array([0x61]));
expect(base64UrlDecode('YWI')).toEqual(new Uint8Array([0x61, 0x62]));
expect(base64UrlDecode('YWJj')).toEqual(new Uint8Array([0x61, 0x62, 0x63]));
});
});

View File

@@ -0,0 +1,149 @@
import { describe, test, expect } from 'bun:test';
import {
encodeEnvelope,
looksLikeFileEnvelope,
tryParseEnvelope,
classify,
KIND_LIST_V1,
KIND_ERROR_V1,
KIND_CANCEL_V1,
responseKindOf,
} from '../../src/index.js';
import type { RpcRequest, RpcResponse, RpcError, RpcCancel } from '../../src/index.js';
const ID = 'AbCdEfGhIjKlMnOpQrStUv';
describe('looksLikeFileEnvelope', () => {
test('matches plaintext containing shade.fs', () => {
expect(looksLikeFileEnvelope('{"kind":"shade.fs.list/v1"}')).toBe(true);
});
test('rejects unrelated', () => {
expect(looksLikeFileEnvelope('hello world')).toBe(false);
expect(looksLikeFileEnvelope('{"kind":"shade.stream-init/v1"}')).toBe(false);
});
});
describe('tryParseEnvelope', () => {
test('returns null on non-JSON', () => {
expect(tryParseEnvelope('not json {{')).toBeNull();
});
test('returns null on JSON that does not match any envelope', () => {
expect(tryParseEnvelope('{"kind":"unknown"}')).toBeNull();
expect(tryParseEnvelope('{"foo":"bar"}')).toBeNull();
});
test('classifies request', () => {
const req: RpcRequest = {
kind: KIND_LIST_V1,
id: ID,
args: { path: '/' },
sig: 'abc',
signedAt: 1,
};
const c = tryParseEnvelope(encodeEnvelope(req));
expect(c?.kind).toBe('request');
});
test('classifies response', () => {
const resp: RpcResponse = {
kind: responseKindOf(KIND_LIST_V1),
id: ID,
result: { entries: [], hasMore: false },
};
const c = tryParseEnvelope(encodeEnvelope(resp));
expect(c?.kind).toBe('response');
});
test('classifies error', () => {
const err: RpcError = {
kind: KIND_ERROR_V1,
id: ID,
error: { code: 'NOT_FOUND', message: 'missing' },
};
const c = tryParseEnvelope(encodeEnvelope(err));
expect(c?.kind).toBe('error');
});
test('classifies cancel', () => {
const cancel: RpcCancel = {
kind: KIND_CANCEL_V1,
id: ID,
reason: 'user-cancel',
};
const c = tryParseEnvelope(encodeEnvelope(cancel));
expect(c?.kind).toBe('cancel');
});
test('rejects request with missing fields', () => {
expect(tryParseEnvelope(JSON.stringify({ kind: KIND_LIST_V1 }))).toBeNull();
expect(
tryParseEnvelope(
JSON.stringify({ kind: KIND_LIST_V1, id: ID, args: {}, sig: 'x' }),
),
).toBeNull(); // missing signedAt
});
test('rejects response shape with non-.response suffix and no signature', () => {
// Missing both `.response` suffix (so not a response) AND missing
// `sig`/`signedAt` (so not a valid request) → no schema matches.
expect(
tryParseEnvelope(
JSON.stringify({ kind: 'shade.fs.list/v1', id: ID, result: {} }),
),
).toBeNull();
});
test('rejects malformed id length', () => {
const req = {
kind: KIND_LIST_V1,
id: 'short',
args: {},
sig: 'x',
signedAt: 1,
};
expect(tryParseEnvelope(JSON.stringify(req))).toBeNull();
});
});
describe('classify on already-validated envelopes', () => {
test('correct discriminator on each branch', () => {
const req: RpcRequest = {
kind: KIND_LIST_V1, id: ID, args: {}, sig: 'x', signedAt: 1,
};
expect(classify(req).kind).toBe('request');
const resp: RpcResponse = {
kind: responseKindOf(KIND_LIST_V1), id: ID, result: {},
};
expect(classify(resp).kind).toBe('response');
const err: RpcError = {
kind: KIND_ERROR_V1, id: ID, error: { code: 'NOT_FOUND', message: '' },
};
expect(classify(err).kind).toBe('error');
const cancel: RpcCancel = { kind: KIND_CANCEL_V1, id: ID };
expect(classify(cancel).kind).toBe('cancel');
});
});
describe('encodeEnvelope', () => {
test('roundtrips request envelope', () => {
const req: RpcRequest = {
kind: KIND_LIST_V1,
id: ID,
args: { path: '/foo' },
idempotencyKey: 'IdemKeyAaBbCcDdEeFfGgH',
attempt: 1,
sig: 'sig',
signedAt: 1730000000000,
};
const c = tryParseEnvelope(encodeEnvelope(req));
expect(c?.kind).toBe('request');
if (c?.kind === 'request') {
expect(c.envelope.idempotencyKey).toBe('IdemKeyAaBbCcDdEeFfGgH');
expect(c.envelope.attempt).toBe(1);
}
});
});

View File

@@ -0,0 +1,108 @@
import { describe, test, expect } from 'bun:test';
import { IdempotencyCache, IdempotencyConflictError } from '../../src/index.js';
describe('IdempotencyCache.begin', () => {
test('first call returns fresh', () => {
const cache = new IdempotencyCache();
const result = cache.begin('alice', 'key1', { path: '/foo' });
expect(result.status).toBe('fresh');
});
test('replay returns cached response', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key1', { path: '/foo' });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit({ ok: true });
const b = cache.begin('alice', 'key1', { path: '/foo' });
expect(b.status).toBe('replay');
if (b.status === 'replay') {
expect(b.response).toEqual({ ok: true });
}
});
test('argsHash mismatch throws IdempotencyConflictError', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key1', { path: '/foo' });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit({ ok: true });
expect(() =>
cache.begin('alice', 'key1', { path: '/different' }),
).toThrow(IdempotencyConflictError);
});
test('inflight retry returns wait-promise', async () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key1', { x: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
const b = cache.begin('alice', 'key1', { x: 1 });
expect(b.status).toBe('wait');
if (b.status === 'wait') {
a.commit({ result: 42 });
const v = await b.promise;
expect(v).toEqual({ result: 42 });
}
});
test('different senders are isolated', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'k', { x: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit({ side: 'alice' });
const b = cache.begin('bob', 'k', { x: 1 });
expect(b.status).toBe('fresh');
});
test('abandon removes the entry so retries proceed fresh', () => {
const cache = new IdempotencyCache();
const a = cache.begin('alice', 'key', { p: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.abandon();
const b = cache.begin('alice', 'key', { p: 1 });
expect(b.status).toBe('fresh');
});
});
describe('IdempotencyCache TTL + LRU', () => {
test('expired entries are evicted on next access', async () => {
const cache = new IdempotencyCache({ ttlMs: 5 });
const a = cache.begin('alice', 'k', { x: 1 });
if (a.status !== 'fresh') throw new Error('expected fresh');
a.commit('done');
await new Promise((r) => setTimeout(r, 15));
const b = cache.begin('alice', 'k', { x: 1 });
expect(b.status).toBe('fresh'); // expired → fresh again
});
test('LRU caps per-sender entries', () => {
const cache = new IdempotencyCache({ maxEntriesPerSender: 3 });
for (let i = 0; i < 5; i++) {
const r = cache.begin('alice', `key${i}`, { i });
if (r.status === 'fresh') r.commit(i);
}
expect(cache.size()).toBe(3); // first two evicted
});
});
describe('IdempotencyCache.prune', () => {
test('removes only TTL-expired entries', async () => {
// Two senders so begin() on one doesn't auto-evict the other's expired entry.
const cache = new IdempotencyCache({ ttlMs: 5 });
const a = cache.begin('alice', 'old', { x: 1 });
if (a.status === 'fresh') a.commit(1);
await new Promise((r) => setTimeout(r, 15));
const b = cache.begin('bob', 'new', { x: 2 });
if (b.status === 'fresh') b.commit(2);
const removed = cache.prune();
expect(removed).toBe(1); // alice/old expired; bob/new fresh
expect(cache.size()).toBe(1);
});
});

View File

@@ -0,0 +1,161 @@
import { describe, expect, test } from 'bun:test';
import { decideInline, INLINE_THRESHOLD } from '../../src/client/inline-threshold.js';
const KIB = 1024;
function streamOf(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
let i = 0;
return new ReadableStream<Uint8Array>({
pull(controller) {
if (i < chunks.length) {
controller.enqueue(chunks[i]!);
i++;
} else {
controller.close();
}
},
});
}
async function drainStream(s: ReadableStream<Uint8Array>): Promise<Uint8Array> {
const reader = s.getReader();
const parts: Uint8Array[] = [];
let total = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value === undefined) continue;
parts.push(value);
total += value.byteLength;
}
reader.releaseLock();
const out = new Uint8Array(total);
let offset = 0;
for (const p of parts) {
out.set(p, offset);
offset += p.byteLength;
}
return out;
}
describe('decideInline (Uint8Array)', () => {
test('1 KiB → inline', async () => {
const bytes = new Uint8Array(KIB).fill(0xab);
const decision = await decideInline(bytes);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(KIB);
}
});
test('exactly 256 KiB → inline (boundary)', async () => {
const bytes = new Uint8Array(INLINE_THRESHOLD).fill(0xcd);
const decision = await decideInline(bytes);
expect(decision.kind).toBe('inline');
});
test('256 KiB + 1 → streams (boundary +1)', async () => {
const bytes = new Uint8Array(INLINE_THRESHOLD + 1).fill(0xef);
const decision = await decideInline(bytes);
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
expect(decision.size).toBe(INLINE_THRESHOLD + 1);
const drained = await drainStream(decision.stream);
expect(drained.byteLength).toBe(INLINE_THRESHOLD + 1);
}
});
});
describe('decideInline (Blob)', () => {
test('small Blob → inline + propagates contentType', async () => {
const blob = new Blob([new Uint8Array(100).fill(7)], { type: 'application/octet-stream' });
const decision = await decideInline(blob);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.contentType).toBe('application/octet-stream');
expect(decision.bytes.byteLength).toBe(100);
}
});
test('large Blob → streams + propagates size + contentType', async () => {
const big = new Uint8Array(INLINE_THRESHOLD + KIB).fill(1);
const blob = new Blob([big], { type: 'image/png' });
const decision = await decideInline(blob);
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
expect(decision.size).toBe(big.byteLength);
expect(decision.contentType).toBe('image/png');
}
});
test('empty Blob.type → no contentType', async () => {
const blob = new Blob([new Uint8Array(10)]);
const decision = await decideInline(blob);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.contentType).toBeUndefined();
}
});
});
describe('decideInline (ReadableStream — bare)', () => {
test('EOF before threshold → inline', async () => {
const stream = streamOf(new Uint8Array(100), new Uint8Array(200));
const decision = await decideInline(stream);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(300);
}
});
test('crosses threshold mid-chunk → streams + remainder available', async () => {
// 256 KiB + 1 byte across two chunks
const a = new Uint8Array(200 * KIB).fill(0x10);
const b = new Uint8Array(100 * KIB).fill(0x20);
const stream = streamOf(a, b);
const decision = await decideInline(stream);
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
const drained = await drainStream(decision.stream);
expect(drained.byteLength).toBe(a.byteLength + b.byteLength);
expect(drained[0]).toBe(0x10);
expect(drained[drained.length - 1]).toBe(0x20);
}
});
test('empty stream → inline (zero bytes)', async () => {
const stream = streamOf();
const decision = await decideInline(stream);
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(0);
}
});
});
describe('decideInline ({ stream, size })', () => {
test('declared size ≤ threshold → inline', async () => {
const bytes = new Uint8Array(KIB).fill(9);
const decision = await decideInline({ stream: streamOf(bytes), size: KIB, contentType: 'text/plain' });
expect(decision.kind).toBe('inline');
if (decision.kind === 'inline') {
expect(decision.bytes.byteLength).toBe(KIB);
expect(decision.contentType).toBe('text/plain');
}
});
test('declared size > threshold → streams', async () => {
const big = new Uint8Array(500 * KIB).fill(2);
const decision = await decideInline({ stream: streamOf(big), size: big.byteLength });
expect(decision.kind).toBe('streams');
if (decision.kind === 'streams') {
expect(decision.size).toBe(big.byteLength);
}
});
});
describe('decideInline (errors)', () => {
test('unsupported input throws TypeError', async () => {
await expect(decideInline(42 as unknown as Uint8Array)).rejects.toThrow(TypeError);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, test, expect } from 'bun:test';
import * as fc from 'fast-check';
import { validatePath } from '../../src/index.js';
describe('validatePath — happy path', () => {
test('accepts simple absolute paths', () => {
expect(validatePath('/foo')).toEqual({ ok: true, normalized: '/foo' });
expect(validatePath('/foo/bar/baz.txt')).toEqual({
ok: true,
normalized: '/foo/bar/baz.txt',
});
expect(validatePath('/')).toEqual({ ok: true, normalized: '/' });
});
test('normalizes redundant slashes and dots', () => {
expect(validatePath('//foo//bar/./baz/').normalized).toBe('/foo/bar/baz');
expect(validatePath('/./foo').normalized).toBe('/foo');
});
test('UTF-8 paths are accepted', () => {
expect(validatePath('/Документы/файл.txt').normalized).toBe('/Документы/файл.txt');
expect(validatePath('/絵文字 😀/foo').normalized).toBe('/絵文字 😀/foo');
});
});
describe('validatePath — security', () => {
test('rejects raw `..` segments', () => {
expect(validatePath('/../etc/passwd').ok).toBe(false);
expect(validatePath('/foo/../etc').ok).toBe(false);
expect(validatePath('/..').ok).toBe(false);
});
test('rejects percent-encoded `..`', () => {
expect(validatePath('/%2e%2e/etc').ok).toBe(false);
expect(validatePath('/foo/%2E%2E/etc').ok).toBe(false);
});
test('rejects forbidden control bytes', () => {
expect(validatePath('/foo\x00bar').ok).toBe(false);
expect(validatePath('/foo\r\nbar').ok).toBe(false);
expect(validatePath('/foo\x7f').ok).toBe(false);
expect(validatePath('/foo\x01').ok).toBe(false);
});
test('rejects backslashes (Windows-style)', () => {
expect(validatePath('/foo\\bar').ok).toBe(false);
});
test('rejects relative paths', () => {
expect(validatePath('foo').ok).toBe(false);
expect(validatePath('./foo').ok).toBe(false);
expect(validatePath('').ok).toBe(false);
});
test('rejects over-length paths', () => {
expect(validatePath('/' + 'a'.repeat(4096)).ok).toBe(false);
expect(validatePath('/foobar', { maxLength: 5 }).ok).toBe(false);
expect(validatePath('/abc', { maxLength: 5 }).ok).toBe(true);
});
});
describe('validatePath — rootScope', () => {
test('accepts paths inside scope', () => {
expect(
validatePath('/srv/data/foo', { rootScope: '/srv/data' }).ok,
).toBe(true);
expect(validatePath('/srv/data', { rootScope: '/srv/data' }).ok).toBe(true);
});
test('rejects paths outside scope', () => {
expect(validatePath('/etc/passwd', { rootScope: '/srv/data' }).ok).toBe(false);
expect(validatePath('/srv/dataX', { rootScope: '/srv/data' }).ok).toBe(false);
// Boundary check: /srv/database is NOT inside /srv/data
expect(validatePath('/srv/database/x', { rootScope: '/srv/data' }).ok).toBe(false);
});
});
describe('validatePath — extra hook', () => {
test('extra reject takes precedence', () => {
const result = validatePath('/secret/foo', {
extra: (p) => (p.includes('secret') ? 'reject' : 'allow'),
});
expect(result.ok).toBe(false);
});
test('extra allow is the default', () => {
expect(
validatePath('/foo', {
extra: () => 'allow',
}).ok,
).toBe(true);
});
});
describe('validatePath — property-based', () => {
test('any string with a forbidden control byte is rejected', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 100 }),
fc.constantFrom('\x00', '\x07', '\x0a', '\x0d', '\x7f', '\\'),
(prefix, bad) => {
const p = `/${prefix}${bad}`;
expect(validatePath(p).ok).toBe(false);
},
),
{ numRuns: 200 },
);
});
test('any path inside rootScope normalizes within rootScope', () => {
fc.assert(
fc.property(
fc.array(fc.string({ minLength: 1, maxLength: 20 }).filter(
(s) => /^[A-Za-z0-9_-]+$/.test(s),
), { minLength: 1, maxLength: 5 }),
(segments) => {
const root = '/srv';
const path = `${root}/${segments.join('/')}`;
const r = validatePath(path, { rootScope: root });
if (r.ok) {
expect(r.normalized.startsWith(root)).toBe(true);
}
},
),
{ numRuns: 200 },
);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, test, expect } from 'bun:test';
import {
FsRateLimitError,
QuotaExceededError,
RateLimiter,
} from '../../src/index.js';
describe('RateLimiter — op bucket', () => {
test('allows up to capacity then rejects', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 5, opCost: { default: 1 } });
for (let i = 0; i < 5; i++) rl.acquire('alice', 'list');
expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError);
});
test('rejection includes retryAfterMs', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1, opCost: { default: 1 } });
rl.acquire('alice', 'list');
try {
rl.acquire('alice', 'list');
throw new Error('expected throw');
} catch (err) {
expect(err).toBeInstanceOf(FsRateLimitError);
const payload = (err as FsRateLimitError).payload;
expect(payload.retryAfterMs).toBeGreaterThan(0);
}
});
test('different op costs respected', () => {
const rl = new RateLimiter({
maxOpsPerMinutePerSender: 10,
opCost: { write: 5, default: 1 },
});
rl.acquire('alice', 'write'); // 10 - 5 = 5 left
rl.acquire('alice', 'write'); // 5 - 5 = 0 left
expect(() => rl.acquire('alice', 'write')).toThrow(FsRateLimitError);
});
test('per-sender isolation', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 });
rl.acquire('alice', 'list');
rl.acquire('bob', 'list'); // bob's bucket independent
expect(() => rl.acquire('alice', 'list')).toThrow(FsRateLimitError);
expect(() => rl.acquire('bob', 'list')).toThrow(FsRateLimitError);
});
test('release returns tokens', () => {
const rl = new RateLimiter({ maxOpsPerMinutePerSender: 1 });
rl.acquire('alice', 'list');
rl.release('alice', 'list');
rl.acquire('alice', 'list'); // should succeed again
});
});
describe('RateLimiter — byte bucket', () => {
test('quota exceeded triggers QuotaExceededError', () => {
const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 });
rl.acquire('alice', 'write', 600);
rl.acquire('alice', 'write', 400);
expect(() => rl.acquire('alice', 'write', 1)).toThrow(QuotaExceededError);
});
test('reconcile returns over-reserved bytes', () => {
const rl = new RateLimiter({ maxBytesPerHourPerSender: 1000 });
rl.acquire('alice', 'write', 800);
rl.reconcile('alice', 800, 200); // we only used 200 of the 800 reserved
rl.acquire('alice', 'write', 600); // capacity now 400 + 600 = 1000, fits 600
});
test('release returns reserved bytes', () => {
const rl = new RateLimiter({ maxBytesPerHourPerSender: 100 });
rl.acquire('alice', 'read', 80);
rl.release('alice', 'read', 80);
rl.acquire('alice', 'read', 80);
});
});
describe('RateLimiter — refill', () => {
test('tokens refill over time', async () => {
const rl = new RateLimiter({
// 6000/min = 100/sec = 0.1/ms; in 50ms we refill 5
maxOpsPerMinutePerSender: 6000,
opCost: { default: 1 },
});
// Drain by 5
for (let i = 0; i < 5; i++) rl.acquire('alice', 'list');
const before = rl.snapshot('alice')!.ops;
await new Promise((r) => setTimeout(r, 50));
const after = rl.snapshot('alice')!.ops;
expect(after).toBeGreaterThan(before);
});
});

View File

@@ -0,0 +1,350 @@
import { describe, test, expect } from 'bun:test';
import {
PathSchema,
RequestIdSchema,
CursorSchema,
Sha256HexSchema,
FileEntrySchema,
FileKindSchema,
ListPageSchema,
ListArgsSchema,
StatArgsSchema,
MkdirArgsSchema,
DeleteArgsSchema,
MoveArgsSchema,
ReadArgsSchema,
ReadResultSchema,
WriteArgsSchema,
GetThumbnailArgsSchema,
GetThumbnailResultSchema,
CustomArgsSchema,
RpcRequestSchema,
RpcResponseSchema,
RpcErrorSchema,
RpcCancelSchema,
FileErrorCodeSchema,
FileErrorPayloadSchema,
} from '../../src/index.js';
describe('PathSchema', () => {
test('accepts absolute paths', () => {
expect(PathSchema.parse('/foo')).toBe('/foo');
expect(PathSchema.parse('/foo/bar/baz.txt')).toBe('/foo/bar/baz.txt');
expect(PathSchema.parse('/')).toBe('/');
});
test('rejects relative paths', () => {
expect(() => PathSchema.parse('foo')).toThrow();
expect(() => PathSchema.parse('./foo')).toThrow();
});
test('rejects NUL/CR/LF/DEL/backslash', () => {
expect(() => PathSchema.parse('/foo\x00bar')).toThrow();
expect(() => PathSchema.parse('/foo\r\n')).toThrow();
expect(() => PathSchema.parse('/foo\x7f')).toThrow();
expect(() => PathSchema.parse('/foo\\bar')).toThrow();
});
test('rejects empty + over-length', () => {
expect(() => PathSchema.parse('')).toThrow();
expect(() => PathSchema.parse('/' + 'a'.repeat(4096))).toThrow();
});
test('accepts UTF-8 in filenames', () => {
expect(PathSchema.parse('/Документы/файл.txt')).toBe('/Документы/файл.txt');
expect(PathSchema.parse('/πρόβλημα/αρχείο')).toBe('/πρόβλημα/αρχείο');
});
});
describe('RequestIdSchema', () => {
test('accepts 22-char base64url', () => {
expect(RequestIdSchema.parse('AbCdEfGhIjKlMnOpQrStUv')).toBe('AbCdEfGhIjKlMnOpQrStUv');
});
test('rejects wrong length', () => {
expect(() => RequestIdSchema.parse('AbCd')).toThrow();
expect(() => RequestIdSchema.parse('AbCdEfGhIjKlMnOpQrStUvWxYz')).toThrow();
});
test('rejects non-base64url chars', () => {
expect(() => RequestIdSchema.parse('AbCd/EfGh+IjKlMnOpQrST')).toThrow();
expect(() => RequestIdSchema.parse('AbCd=EfGhIjKlMnOpQrSTUV')).toThrow();
});
});
describe('CursorSchema', () => {
test('accepts up to 2048 chars', () => {
expect(CursorSchema.parse('a')).toBe('a');
expect(CursorSchema.parse('x'.repeat(2048)).length).toBe(2048);
});
test('rejects empty + over-length', () => {
expect(() => CursorSchema.parse('')).toThrow();
expect(() => CursorSchema.parse('x'.repeat(2049))).toThrow();
});
});
describe('Sha256HexSchema', () => {
test('accepts 64 hex chars', () => {
const h = '0'.repeat(64);
expect(Sha256HexSchema.parse(h)).toBe(h);
});
test('rejects wrong length / non-hex', () => {
expect(() => Sha256HexSchema.parse('0'.repeat(63))).toThrow();
expect(() => Sha256HexSchema.parse('0'.repeat(65))).toThrow();
expect(() => Sha256HexSchema.parse('g'.repeat(64))).toThrow();
expect(() => Sha256HexSchema.parse('A'.repeat(64))).toThrow(); // uppercase rejected
});
});
describe('FileKind / FileEntry', () => {
test('FileKind accepts file/dir', () => {
expect(FileKindSchema.parse('file')).toBe('file');
expect(FileKindSchema.parse('dir')).toBe('dir');
expect(() => FileKindSchema.parse('symlink')).toThrow();
});
test('FileEntry roundtrip', () => {
const e = {
name: 'foo.txt',
kind: 'file' as const,
size: 1024,
mtime: 1730000000000,
contentType: 'text/plain',
};
const parsed = FileEntrySchema.parse(e);
expect(parsed.name).toBe('foo.txt');
expect(parsed.metadata).toEqual({}); // default
});
test('FileEntry rejects path separators in name', () => {
expect(() =>
FileEntrySchema.parse({ name: 'foo/bar', kind: 'file', size: 0, mtime: 0 }),
).toThrow();
expect(() =>
FileEntrySchema.parse({ name: 'foo\\bar', kind: 'file', size: 0, mtime: 0 }),
).toThrow();
});
test('FileEntry rejects negative size', () => {
expect(() =>
FileEntrySchema.parse({ name: 'a', kind: 'file', size: -1, mtime: 0 }),
).toThrow();
});
test('FileEntry passes through metadata', () => {
const parsed = FileEntrySchema.parse({
name: 'mod.jar',
kind: 'file',
size: 100,
mtime: 0,
metadata: { modrinthId: 'abc', version: '1.0' },
});
expect(parsed.metadata).toEqual({ modrinthId: 'abc', version: '1.0' });
});
});
describe('ListPage', () => {
test('hasMore + nextCursor when more pages', () => {
const p = ListPageSchema.parse({
entries: [],
hasMore: true,
nextCursor: 'abc',
});
expect(p.hasMore).toBe(true);
expect(p.nextCursor).toBe('abc');
});
test('no nextCursor when hasMore false', () => {
const p = ListPageSchema.parse({ entries: [], hasMore: false });
expect(p.nextCursor).toBeUndefined();
});
});
describe('ListArgs', () => {
test('defaults pageSize to 100', () => {
const a = ListArgsSchema.parse({ path: '/foo' });
expect(a.pageSize).toBe(100);
});
test('rejects pageSize > 1000', () => {
expect(() => ListArgsSchema.parse({ path: '/foo', pageSize: 1001 })).toThrow();
});
test('rejects pageSize < 1', () => {
expect(() => ListArgsSchema.parse({ path: '/foo', pageSize: 0 })).toThrow();
});
});
describe('StatArgs', () => {
test('requires path', () => {
expect(() => StatArgsSchema.parse({})).toThrow();
expect(StatArgsSchema.parse({ path: '/foo' }).path).toBe('/foo');
});
});
describe('Mkdir/Delete/Move args', () => {
test('Mkdir defaults recursive=false', () => {
expect(MkdirArgsSchema.parse({ path: '/a' }).recursive).toBe(false);
});
test('Delete defaults recursive=false', () => {
expect(DeleteArgsSchema.parse({ path: '/a' }).recursive).toBe(false);
});
test('Move defaults overwrite=false', () => {
expect(
MoveArgsSchema.parse({ src: '/a', dst: '/b' }).overwrite,
).toBe(false);
});
});
describe('ReadArgs / ReadResult', () => {
test('Read accepts optional range', () => {
const a = ReadArgsSchema.parse({ path: '/a', range: { start: 0, end: 100 } });
expect(a.range).toEqual({ start: 0, end: 100 });
});
test('ReadResult inline', () => {
const r = ReadResultSchema.parse({
kind: 'inline',
bytesB64: 'YQ==',
size: 1,
sha256: 'a'.repeat(64),
});
expect(r.kind).toBe('inline');
});
test('ReadResult streams', () => {
const r = ReadResultSchema.parse({
kind: 'streams',
streamId: 'sid-123',
size: 1024,
sha256: 'b'.repeat(64),
});
expect(r.kind).toBe('streams');
});
test('ReadResult rejects unknown kind', () => {
expect(() =>
ReadResultSchema.parse({ kind: 'magic', bytesB64: 'YQ==' }),
).toThrow();
});
});
describe('WriteArgs', () => {
test('inline shape', () => {
const w = WriteArgsSchema.parse({
kind: 'inline',
path: '/foo',
bytesB64: 'YQ==',
});
expect(w.kind).toBe('inline');
if (w.kind === 'inline') expect(w.overwrite).toBe(false);
});
test('streams shape', () => {
const w = WriteArgsSchema.parse({
kind: 'streams',
path: '/foo',
size: 1000,
writeId: 'AbCdEfGhIjKlMnOpQrStUv',
});
if (w.kind === 'streams') expect(w.size).toBe(1000);
});
});
describe('GetThumbnailArgs / Result', () => {
test('size must be enum 64/128/256/512', () => {
expect(() => GetThumbnailArgsSchema.parse({ path: '/a', size: 100 })).toThrow();
expect(GetThumbnailArgsSchema.parse({ path: '/a', size: 64 }).size).toBe(64);
});
test('format defaults to png', () => {
const a = GetThumbnailArgsSchema.parse({ path: '/a', size: 128 });
expect(a.format).toBe('png');
});
test('Result requires width/height/sha256', () => {
const r = GetThumbnailResultSchema.parse({
bytesB64: 'YQ==',
format: 'png',
width: 128,
height: 128,
sha256: 'c'.repeat(64),
});
expect(r.width).toBe(128);
});
});
describe('CustomArgs', () => {
test('accepts dotted op names', () => {
expect(
CustomArgsSchema.parse({ name: 'dispatch.deploy-mod', payload: {} }).name,
).toBe('dispatch.deploy-mod');
});
test('rejects names with spaces or slashes', () => {
expect(() => CustomArgsSchema.parse({ name: 'foo bar', payload: {} })).toThrow();
expect(() => CustomArgsSchema.parse({ name: 'foo/bar', payload: {} })).toThrow();
});
});
describe('RPC envelopes', () => {
test('RpcRequest valid', () => {
const env = RpcRequestSchema.parse({
kind: 'shade.fs.list/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
args: { path: '/' },
sig: 'sig',
signedAt: 1730000000000,
});
expect(env.kind).toBe('shade.fs.list/v1');
});
test('RpcRequest rejects malformed kind', () => {
expect(() =>
RpcRequestSchema.parse({
kind: 'shade.fs.list',
id: 'AbCdEfGhIjKlMnOpQrStUv',
args: {},
sig: 's',
signedAt: 1,
}),
).toThrow();
});
test('RpcResponse expects .response suffix', () => {
const env = RpcResponseSchema.parse({
kind: 'shade.fs.list/v1.response',
id: 'AbCdEfGhIjKlMnOpQrStUv',
result: { entries: [], hasMore: false },
});
expect(env.id.length).toBe(22);
});
test('RpcError fixed kind', () => {
const env = RpcErrorSchema.parse({
kind: 'shade.fs.error/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
error: { code: 'NOT_FOUND', message: 'gone' },
});
expect(env.error.code).toBe('NOT_FOUND');
});
test('RpcCancel fixed kind', () => {
const env = RpcCancelSchema.parse({
kind: 'shade.fs.cancel/v1',
id: 'AbCdEfGhIjKlMnOpQrStUv',
reason: 'user-cancel',
});
expect(env.reason).toBe('user-cancel');
});
});
describe('FileError envelope', () => {
test('all known codes parse', () => {
const codes: ReadonlyArray<unknown> = [
'NOT_FOUND',
'PERMISSION_DENIED',
'CONFLICT',
'QUOTA_EXCEEDED',
'RATE_LIMIT',
'PATH_VALIDATION',
'FINGERPRINT_REQUIRED',
'OPERATION_TIMEOUT',
'IDEMPOTENCY_CONFLICT',
'CANCELLED',
'INTERNAL',
'NOT_IMPLEMENTED',
'CUSTOM_OP_REJECTED',
'INVALID_SIGNATURE',
'INVALID_ARGS',
];
for (const c of codes) expect(FileErrorCodeSchema.parse(c)).toBe(c);
});
test('rejects unknown code', () => {
expect(() => FileErrorCodeSchema.parse('NOT_REAL')).toThrow();
});
test('FileErrorPayload includes optional fields', () => {
const p = FileErrorPayloadSchema.parse({
code: 'RATE_LIMIT',
message: 'too fast',
retryAfterMs: 1000,
});
expect(p.retryAfterMs).toBe(1000);
});
});