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) { 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(); }); });