Initial Commit
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user