AGENTS.md asks for top-level `import type` instead of inline `type` specifiers; this applies it to the import statements the media work added or changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
199 lines
7.5 KiB
TypeScript
199 lines
7.5 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdtemp, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import type { AdapterContext } from "@rakazo/adapter-kit";
|
|
import { ArtifactTooLargeError } from "@rakazo/adapter-kit";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { LocalArtifactStore } from "./artifacts.js";
|
|
|
|
const dirs: string[] = [];
|
|
const context = { spaceId: "space-1", signal: new AbortController().signal } as AdapterContext;
|
|
|
|
async function tempStore() {
|
|
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
|
|
dirs.push(root);
|
|
return { root, store: new LocalArtifactStore(root) };
|
|
}
|
|
|
|
async function readAll(stream: ReadableStream<Uint8Array>) {
|
|
return Buffer.from(await new Response(stream).arrayBuffer());
|
|
}
|
|
|
|
async function* chunks(...parts: string[]) {
|
|
for (const part of parts) yield new TextEncoder().encode(part);
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
describe("LocalArtifactStore", () => {
|
|
it("creates artifact files with owner-only permissions", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
|
|
dirs.push(root);
|
|
const store = new LocalArtifactStore(root);
|
|
|
|
const stored = await store.put(
|
|
{ name: "private.txt", mimeType: "text/plain", bytes: new TextEncoder().encode("private") },
|
|
context,
|
|
);
|
|
|
|
const info = await stat(path.join(root, "artifacts", "space-1", stored.id));
|
|
expect(info.mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it("does not follow a replacement symlink when reading an artifact", async () => {
|
|
const root = await mkdtemp(path.join(tmpdir(), "rakazo-artifacts-"));
|
|
dirs.push(root);
|
|
const store = new LocalArtifactStore(root);
|
|
const stored = await store.put(
|
|
{ name: "private.txt", mimeType: "text/plain", bytes: new TextEncoder().encode("private") },
|
|
context,
|
|
);
|
|
const file = path.join(root, "artifacts", "space-1", stored.id);
|
|
const target = path.join(root, "outside.txt");
|
|
await writeFile(target, "outside");
|
|
await rm(file);
|
|
await symlink(target, file);
|
|
|
|
await expect(store.get(stored.id, context)).rejects.toThrow();
|
|
});
|
|
|
|
it("streams uploads to disk with a real hash, size and owner-only mode", async () => {
|
|
const { root, store } = await tempStore();
|
|
const stored = await store.putStream(
|
|
{
|
|
name: "clip.mp4",
|
|
mimeType: "video/mp4",
|
|
stream: new Response("hello media world").body!,
|
|
maxBytes: 1024,
|
|
},
|
|
context,
|
|
);
|
|
expect(stored.size).toBe(17);
|
|
expect(stored.sha256).toBe(createHash("sha256").update("hello media world").digest("hex"));
|
|
const info = await stat(path.join(root, "artifacts", "space-1", stored.key));
|
|
expect(info.mode & 0o777).toBe(0o600);
|
|
expect((await store.stat(stored.key, context)).size).toBe(17);
|
|
expect(await store.localPath(stored.key, context)).toBe(
|
|
path.join(root, "artifacts", "space-1", stored.key),
|
|
);
|
|
expect((await store.get(stored.key, context)).byteLength).toBe(17);
|
|
});
|
|
|
|
it("accepts async iterables and rejects bodies over the cap without leaving a file", async () => {
|
|
const { root, store } = await tempStore();
|
|
const ok = await store.putStream(
|
|
{ name: "a.txt", mimeType: "text/plain", stream: chunks("ab", "cd"), maxBytes: 4 },
|
|
context,
|
|
);
|
|
expect(ok.size).toBe(4);
|
|
await expect(
|
|
store.putStream(
|
|
{ name: "b.txt", mimeType: "text/plain", stream: chunks("abc", "de"), maxBytes: 4 },
|
|
context,
|
|
),
|
|
).rejects.toBeInstanceOf(ArtifactTooLargeError);
|
|
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([ok.key]);
|
|
});
|
|
|
|
it("stops and cleans up when the run is cancelled mid-upload", async () => {
|
|
const { root, store } = await tempStore();
|
|
const controller = new AbortController();
|
|
async function* aborting() {
|
|
yield new TextEncoder().encode("first");
|
|
controller.abort();
|
|
yield new TextEncoder().encode("second");
|
|
}
|
|
await expect(
|
|
store.putStream(
|
|
{ name: "c.txt", mimeType: "text/plain", stream: aborting(), maxBytes: 100 },
|
|
{ ...context, signal: controller.signal },
|
|
),
|
|
).rejects.toThrow();
|
|
expect(await readdir(path.join(root, "artifacts", "space-1"))).toEqual([]);
|
|
});
|
|
|
|
it("reads whole artifacts and inclusive byte ranges", async () => {
|
|
const { store } = await tempStore();
|
|
const stored = await store.putStream(
|
|
{ name: "n.txt", mimeType: "text/plain", stream: chunks("0123456789"), maxBytes: 100 },
|
|
context,
|
|
);
|
|
expect((await readAll(await store.createReadStream(stored.key, context))).toString()).toBe(
|
|
"0123456789",
|
|
);
|
|
const middle = await store.createReadStream(stored.key, context, { start: 2, end: 5 });
|
|
expect((await readAll(middle)).toString()).toBe("2345");
|
|
const last = await store.createReadStream(stored.key, context, { start: 9, end: 9 });
|
|
expect((await readAll(last)).toString()).toBe("9");
|
|
for (const range of [
|
|
{ start: 0, end: 10 },
|
|
{ start: 5, end: 4 },
|
|
{ start: -1, end: 3 },
|
|
{ start: 1.5, end: 3 },
|
|
]) {
|
|
await expect(store.createReadStream(stored.key, context, range)).rejects.toBeInstanceOf(
|
|
RangeError,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("streams large artifacts in bounded chunks", async () => {
|
|
const { store } = await tempStore();
|
|
const bytes = Buffer.alloc(700 * 1024, 7);
|
|
const stored = await store.putStream(
|
|
{
|
|
name: "big.bin",
|
|
mimeType: "text/plain",
|
|
stream: new Response(bytes).body!,
|
|
maxBytes: bytes.length,
|
|
},
|
|
context,
|
|
);
|
|
const reader = (await store.createReadStream(stored.key, context)).getReader();
|
|
let total = 0;
|
|
let largest = 0;
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
total += value.byteLength;
|
|
largest = Math.max(largest, value.byteLength);
|
|
}
|
|
expect(total).toBe(bytes.length);
|
|
expect(largest).toBeLessThanOrEqual(256 * 1024);
|
|
});
|
|
|
|
it("rejects keys and spaces that could address another path", async () => {
|
|
const { store } = await tempStore();
|
|
for (const key of ["../space-2/x", "a/b", ".hidden", "", "x".repeat(200)]) {
|
|
await expect(store.stat(key, context)).rejects.toThrow("Invalid artifact key");
|
|
await expect(store.createReadStream(key, context)).rejects.toThrow("Invalid artifact key");
|
|
await expect(store.remove(key, context)).rejects.toThrow("Invalid artifact key");
|
|
}
|
|
await expect(
|
|
store.putStream(
|
|
{ name: "x", mimeType: "text/plain", stream: chunks("x"), maxBytes: 10 },
|
|
{ ...context, spaceId: "../escape" },
|
|
),
|
|
).rejects.toThrow("Invalid artifact space");
|
|
});
|
|
|
|
it("does not follow a replacement symlink when streaming or resolving a local path", async () => {
|
|
const { root, store } = await tempStore();
|
|
const stored = await store.putStream(
|
|
{ name: "p.txt", mimeType: "text/plain", stream: chunks("private"), maxBytes: 100 },
|
|
context,
|
|
);
|
|
const file = path.join(root, "artifacts", "space-1", stored.key);
|
|
const target = path.join(root, "outside.txt");
|
|
await writeFile(target, "outside");
|
|
await rm(file);
|
|
await symlink(target, file);
|
|
await expect(store.createReadStream(stored.key, context)).rejects.toThrow();
|
|
await expect(store.stat(stored.key, context)).rejects.toThrow();
|
|
await expect(store.localPath(stored.key, context)).rejects.toThrow();
|
|
});
|
|
});
|