478 lines
15 KiB
TypeScript
478 lines
15 KiB
TypeScript
import {
|
|
getPublicKey,
|
|
randomPrivateKey,
|
|
} from "@suaveplan/crypto-curve/ed25519";
|
|
import { Drive } from "@suaveplan/drive";
|
|
import { MemoryStorage } from "@suaveplan/storage/memory";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import { parseChunk } from "./codec/chunk.js";
|
|
import { buildManifest } from "./manifest/build.js";
|
|
import { chunksPlugin } from "./plugin.js";
|
|
|
|
function createDriveStorage(id: string) {
|
|
return Object.assign(new MemoryStorage(), {
|
|
id,
|
|
}) as import("@suaveplan/drive").DriveStorage;
|
|
}
|
|
|
|
describe("CHUNKS-INVARIANT-01 — Store-as-is", () => {
|
|
it("putChunk persists raw bytes unchanged", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
})
|
|
);
|
|
|
|
await drive.write("test.txt", new TextEncoder().encode("hello world"));
|
|
const fileId = await chunks.ingest("test.txt");
|
|
|
|
const raw = await chunks.getChunk(fileId, 0);
|
|
expect(raw).toBeDefined();
|
|
const parsed = parseChunk(raw!);
|
|
expect(parsed.metadata.fileId).toEqual(fileId);
|
|
|
|
await drive.dispose();
|
|
});
|
|
});
|
|
|
|
describe("CHUNKS-VERIFY-01 — Sig-verify gate on receipt", () => {
|
|
it("putChunk rejects invalid signature before persist", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const _pubKey = getPublicKey(privKey);
|
|
const wrongPubKey = getPublicKey(randomPrivateKey());
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: wrongPubKey,
|
|
chunkSize: 1024,
|
|
})
|
|
);
|
|
|
|
await drive.write("test.txt", new TextEncoder().encode("hello world"));
|
|
const fileId = await chunks.ingest("test.txt");
|
|
|
|
const raw = await chunks.getChunk(fileId, 0);
|
|
expect(raw).toBeDefined();
|
|
|
|
// Now try to put the same chunk with a different pubKey (wrong signer)
|
|
const chunks2 = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: wrongPubKey,
|
|
chunkSize: 1024,
|
|
})
|
|
);
|
|
|
|
await expect(chunks2.putChunk(raw!)).rejects.toThrow();
|
|
try {
|
|
await chunks2.putChunk(raw!);
|
|
} catch (e) {
|
|
expect((e as { code?: string }).code).toBe("CHUNKS.SIG_INVALID");
|
|
}
|
|
|
|
await drive.dispose();
|
|
});
|
|
});
|
|
|
|
describe("CHUNKS-READ-01 — Read middleware reassembles", () => {
|
|
it("read returns original plaintext after ingest", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
})
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode(
|
|
"hello world from chunked drive"
|
|
);
|
|
await drive.write("test.txt", plaintext);
|
|
await chunks.ingest("test.txt");
|
|
|
|
const result = await drive.read("test.txt");
|
|
expect(result).toEqual(plaintext);
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("throws when chunk is missing during reassembly", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
// Use chunk-keys layout so we can delete individual chunks
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
layout: "chunk-keys",
|
|
})
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode("x".repeat(2000));
|
|
await drive.write("test.txt", plaintext);
|
|
const fileId = await chunks.ingest("test.txt");
|
|
|
|
// Delete chunk 1 to simulate corruption
|
|
await storage.delete(
|
|
`chunks/${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}/1`
|
|
);
|
|
|
|
await expect(drive.read("test.txt")).rejects.toThrow();
|
|
try {
|
|
await drive.read("test.txt");
|
|
} catch (e) {
|
|
expect((e as { code?: string }).code).toBe("CHUNKS.FILE_NOT_FOUND");
|
|
}
|
|
|
|
await drive.dispose();
|
|
});
|
|
});
|
|
|
|
describe("CHUNKS-PLUGIN-INIT-01 — Storage resolution", () => {
|
|
it("throws when drive has no getStorage", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const fakeDrive = {
|
|
useMiddleware: () => {},
|
|
getStorage: () => {
|
|
throw new Error("no storage");
|
|
},
|
|
} as unknown as import("@suaveplan/drive").DriveLike;
|
|
|
|
const plugin = chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
});
|
|
await expect(plugin.install(fakeDrive)).rejects.toThrow();
|
|
try {
|
|
await plugin.install(fakeDrive);
|
|
} catch (e) {
|
|
expect((e as { code?: string }).code).toBe("CHUNKS.FILE_NOT_FOUND");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("CHUNKS-INGEST-01 — Edge cases", () => {
|
|
it("ingests empty file", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
})
|
|
);
|
|
|
|
await drive.write("empty.txt", new Uint8Array(0));
|
|
const fileId = await chunks.ingest("empty.txt");
|
|
|
|
// Empty file produces zero chunks, but manifest should exist
|
|
const manifestRaw = await storage.get(
|
|
`manifest:${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}`
|
|
);
|
|
expect(manifestRaw).toBeDefined();
|
|
|
|
await drive.dispose();
|
|
});
|
|
});
|
|
|
|
describe("CHUNKS-SOURCE-01 — ChunkSource interface", () => {
|
|
it("hasChunks and availableChunks reflect stored chunks", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
layout: "chunk-keys",
|
|
})
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode(
|
|
"hello world from chunked drive"
|
|
);
|
|
await drive.write("test.txt", plaintext);
|
|
const fileId = await chunks.ingest("test.txt");
|
|
|
|
const has = await chunks.hasChunks(fileId);
|
|
expect(has[0]).toBe(true);
|
|
|
|
const available = Array.from(await chunks.availableChunks(fileId));
|
|
expect(available).toEqual([0]);
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("putChunk stores a valid chunk", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
layout: "chunk-keys",
|
|
})
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode(
|
|
"hello world from chunked drive"
|
|
);
|
|
await drive.write("test.txt", plaintext);
|
|
const fileId = await chunks.ingest("test.txt");
|
|
|
|
const raw = await chunks.getChunk(fileId, 0);
|
|
expect(raw).toBeDefined();
|
|
|
|
// Re-put the same chunk (valid signature)
|
|
await chunks.putChunk(raw!);
|
|
|
|
// Verify it was stored
|
|
const stored = await chunks.getChunk(fileId, 0);
|
|
expect(stored).toBeDefined();
|
|
expect(stored!).toEqual(raw!);
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("exposes layout name", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
layout: "chunk-keys",
|
|
})
|
|
);
|
|
|
|
expect(chunks.layout).toBe("chunk-keys");
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("uses default chunkSize when not specified", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({ serverPrivKey: privKey, serverPubKey: pubKey })
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode("hello world");
|
|
await drive.write("default-size.txt", plaintext);
|
|
const fileId = await chunks.ingest("default-size.txt");
|
|
expect(fileId).toBeDefined();
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("uses explicit sparse-file layout", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
layout: "sparse-file",
|
|
})
|
|
);
|
|
|
|
expect(chunks.layout).toBe("sparse-file");
|
|
|
|
const plaintext = new TextEncoder().encode("hello world");
|
|
await drive.write("sparse.txt", plaintext);
|
|
const fileId = await chunks.ingest("sparse.txt");
|
|
expect(fileId).toBeDefined();
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("throws when drive.getStorage is missing", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const fakeDrive = {
|
|
useMiddleware: () => {},
|
|
} as unknown as import("@suaveplan/drive").DriveLike;
|
|
|
|
const plugin = chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
});
|
|
await expect(plugin.install(fakeDrive)).rejects.toThrow();
|
|
try {
|
|
await plugin.install(fakeDrive);
|
|
} catch (e) {
|
|
expect((e as { code?: string }).code).toBe("CHUNKS.FILE_NOT_FOUND");
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("CHUNKS-MANIFEST-LOAD-01 — Manifest loading", () => {
|
|
it("throws when manifest is missing", { timeout: 5000 }, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
})
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode("hello world");
|
|
await drive.write("test.txt", plaintext);
|
|
const fileId = await chunks.ingest("test.txt");
|
|
|
|
// Delete the manifest
|
|
await storage.delete(
|
|
`manifest:${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}`
|
|
);
|
|
|
|
await expect(drive.read("test.txt")).rejects.toThrow();
|
|
try {
|
|
await drive.read("test.txt");
|
|
} catch (e) {
|
|
expect((e as { code?: string }).code).toBe(
|
|
"CHUNKS.MANIFEST_NOT_FOUND"
|
|
);
|
|
}
|
|
|
|
await drive.dispose();
|
|
});
|
|
|
|
it("loads manifest from server when configured", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const privKey = randomPrivateKey();
|
|
const pubKey = getPublicKey(privKey);
|
|
const storage = createDriveStorage("default");
|
|
const drive = new Drive(storage);
|
|
await drive.initialize();
|
|
|
|
const manifest = buildManifest({
|
|
fileId: new Uint8Array(32),
|
|
fileSize: 1n,
|
|
chunkSize: 65536,
|
|
totalChunks: 1,
|
|
encryption: {
|
|
algo: "xchacha20poly1305",
|
|
fileKeyWrapped: new Uint8Array(32),
|
|
baseNonce: new Uint8Array(24),
|
|
},
|
|
chunks: [{ chunkId: 0, hash: new Uint8Array(32), payloadLen: 1 }],
|
|
serverPrivKey: privKey,
|
|
});
|
|
|
|
const raw = new TextEncoder().encode(
|
|
JSON.stringify(manifest, (_k, v) => {
|
|
if (v instanceof Uint8Array)
|
|
return `base64:${btoa(String.fromCharCode(...v))}`;
|
|
if (typeof v === "bigint") return v.toString();
|
|
return v;
|
|
})
|
|
);
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
// @ts-expect-error - mocking global fetch
|
|
globalThis.fetch = async () => ({
|
|
ok: true,
|
|
arrayBuffer: async () => raw.buffer,
|
|
});
|
|
|
|
try {
|
|
const chunks = await drive.usePlugin(
|
|
chunksPlugin({
|
|
serverPrivKey: privKey,
|
|
serverPubKey: pubKey,
|
|
chunkSize: 1024,
|
|
manifestSource: "server-only",
|
|
manifestServerUrl: "http://test-server",
|
|
})
|
|
);
|
|
|
|
const plaintext = new TextEncoder().encode("hello world");
|
|
await drive.write("server-test.txt", plaintext);
|
|
const fileId = await chunks.ingest("server-test.txt");
|
|
|
|
// Remove local manifest so server path is taken
|
|
await storage.delete(
|
|
`manifest:${Array.from(fileId, (b) => b.toString(16).padStart(2, "0")).join("")}`
|
|
);
|
|
|
|
// loadManifest should call the server
|
|
const loaded = await chunks.loadManifest(fileId);
|
|
expect(loaded.version).toBe(0x0001);
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
|
|
await drive.dispose();
|
|
});
|
|
});
|