Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 144 additions & 15 deletions front/lib/api/sandbox_functions/build_on_sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from "node:crypto";

import { ensurePodSandboxReady } from "@app/lib/api/sandbox/lifecycle";
import { buildSandboxFunctionOnSandbox } from "@app/lib/api/sandbox_functions/build_on_sandbox";
import { SandboxResource } from "@app/lib/resources/sandbox_resource";
Expand All @@ -15,6 +17,48 @@ const SRC = "/files/pod-spc123/greet.ts";

const okEnvelope = JSON.stringify({ ok: true });

function sha256Hex(content: string): string {
return createHash("sha256").update(content).digest("hex");
}

/**
* Mocks the producing exec: extracts the bundle/schema staging paths from the command and
* returns the build envelope followed by the integrity marker and per-file sha256 lines for
* `contents`, mirroring what the real `sha256sum` capture prints.
*/
function mockExecWithHashes(
sandbox: SandboxResource,
contents: Record<"bundle.js" | "schema.json", string>
) {
return vi
.spyOn(sandbox, "exec")
.mockImplementation(async (_auth, command) => {
// Paths are shell-quoted in the command; strip the surrounding single quotes.
const paths = [
...new Set(
[
...command.matchAll(
/'?(\/[\w./-]+\/(?:bundle\.js|schema\.json))'?/g
),
].map((m) => m[1])
),
];
const hashLines = paths
.map((p) => {
const name = p.endsWith("bundle.js") ? "bundle.js" : "schema.json";
return `${sha256Hex(contents[name])} ${p}`;
})
.join("\n");
return new Ok({
exitCode: 0,
stdout: `${okEnvelope}\n__DUST_STAGING_SHA256__\n${hashLines}\n`,
stderr: "",
});
});
}

const BUNDLE_CONTENT = "export default {/*bundle*/};";

const validSchemaFile = JSON.stringify({
name: "greet",
description: "Greet someone.",
Expand Down Expand Up @@ -57,16 +101,13 @@ beforeEach(() => {
describe("buildSandboxFunctionOnSandbox", () => {
it("builds the bundle and returns the extracted contract", async () => {
const { authenticator, sandbox, space } = await setup();
const execSpy = vi
.spyOn(sandbox, "exec")
.mockResolvedValue(
new Ok({ exitCode: 0, stdout: okEnvelope, stderr: "" })
);
const execSpy = mockExecWithHashes(sandbox, {
"bundle.js": BUNDLE_CONTENT,
"schema.json": validSchemaFile,
});
const readSpy = vi
.spyOn(sandbox, "readFile")
.mockResolvedValueOnce(
new Ok(Buffer.from("export default {/*bundle*/};"))
)
.mockResolvedValueOnce(new Ok(Buffer.from(BUNDLE_CONTENT)))
.mockResolvedValueOnce(new Ok(Buffer.from(validSchemaFile)));

const result = await buildSandboxFunctionOnSandbox(authenticator, {
Expand All @@ -78,7 +119,7 @@ describe("buildSandboxFunctionOnSandbox", () => {
if (result.isErr()) {
return;
}
expect(result.value.bundleCode).toBe("export default {/*bundle*/};");
expect(result.value.bundleCode).toBe(BUNDLE_CONTENT);
expect(result.value.userIdentity).toBe(
"interactive_workspace_user_required"
);
Expand Down Expand Up @@ -175,9 +216,16 @@ describe("buildSandboxFunctionOnSandbox", () => {

it("rejects a function missing an input or output schema", async () => {
const { authenticator, sandbox, space } = await setup();
vi.spyOn(sandbox, "exec").mockResolvedValue(
new Ok({ exitCode: 0, stdout: okEnvelope, stderr: "" })
);
mockExecWithHashes(sandbox, {
"bundle.js": "bundle",
"schema.json": JSON.stringify({
name: "greet",
description: null,
userIdentity: "optional",
input_schema: null,
output_schema: { type: "object" },
}),
});
vi.spyOn(sandbox, "readFile")
.mockResolvedValueOnce(new Ok(Buffer.from("bundle")))
.mockResolvedValueOnce(
Expand Down Expand Up @@ -208,9 +256,15 @@ describe("buildSandboxFunctionOnSandbox", () => {

it("rejects an older sandbox image that omits user identity", async () => {
const { authenticator, sandbox, space } = await setup();
vi.spyOn(sandbox, "exec").mockResolvedValue(
new Ok({ exitCode: 0, stdout: okEnvelope, stderr: "" })
);
mockExecWithHashes(sandbox, {
"bundle.js": "bundle",
"schema.json": JSON.stringify({
name: "greet",
description: null,
input_schema: { type: "object" },
output_schema: { type: "object" },
}),
});
vi.spyOn(sandbox, "readFile")
.mockResolvedValueOnce(new Ok(Buffer.from("bundle")))
.mockResolvedValueOnce(
Expand Down Expand Up @@ -275,6 +329,81 @@ describe("buildSandboxFunctionOnSandbox", () => {
expect(result.error.code).toBe("internal");
});

it("refuses a bundle artifact swapped after the build", async () => {
const { authenticator, sandbox, space } = await setup();
mockExecWithHashes(sandbox, {
"bundle.js": BUNDLE_CONTENT,
"schema.json": validSchemaFile,
});
const swapped = Buffer.from('{"name":"CTF","value":"root-only-content"}');
vi.spyOn(sandbox, "readFile")
.mockResolvedValueOnce(new Ok(swapped))
.mockResolvedValueOnce(new Ok(Buffer.from(validSchemaFile)));

const result = await buildSandboxFunctionOnSandbox(authenticator, {
space,
srcSandboxPath: SRC,
});

expect(result.isErr()).toBe(true);
if (result.isOk()) {
return;
}
expect(result.error.code).toBe("internal");
expect(result.error.message).toContain(
"changed between production and read-back"
);
// The swapped content must not leak through the error path.
expect(result.error.message).not.toContain("root-only-content");
});

it("refuses a schema artifact swapped after the build", async () => {
const { authenticator, sandbox, space } = await setup();
mockExecWithHashes(sandbox, {
"bundle.js": BUNDLE_CONTENT,
"schema.json": validSchemaFile,
});
vi.spyOn(sandbox, "readFile")
.mockResolvedValueOnce(new Ok(Buffer.from(BUNDLE_CONTENT)))
.mockResolvedValueOnce(new Ok(Buffer.from("swapped-schema")));

const result = await buildSandboxFunctionOnSandbox(authenticator, {
space,
srcSandboxPath: SRC,
});

expect(result.isErr()).toBe(true);
if (result.isOk()) {
return;
}
expect(result.error.code).toBe("internal");
expect(result.error.message).toContain(
"changed between production and read-back"
);
});

it("fails closed when the exec output carries no integrity hashes", async () => {
const { authenticator, sandbox, space } = await setup();
vi.spyOn(sandbox, "exec").mockResolvedValue(
new Ok({ exitCode: 0, stdout: okEnvelope, stderr: "" })
);
vi.spyOn(sandbox, "readFile")
.mockResolvedValueOnce(new Ok(Buffer.from(BUNDLE_CONTENT)))
.mockResolvedValueOnce(new Ok(Buffer.from(validSchemaFile)));

const result = await buildSandboxFunctionOnSandbox(authenticator, {
space,
srcSandboxPath: SRC,
});

expect(result.isErr()).toBe(true);
if (result.isOk()) {
return;
}
expect(result.error.code).toBe("internal");
expect(result.error.message).toContain("Missing integrity hash");
});

it("maps a sandbox failure to sandbox_unavailable", async () => {
const { authenticator, space } = await setup();
vi.mocked(ensurePodSandboxReady).mockResolvedValue(
Expand Down
30 changes: 29 additions & 1 deletion front/lib/api/sandbox_functions/build_on_sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { ensurePodSandboxReady } from "@app/lib/api/sandbox/lifecycle";
import { shellEscape } from "@app/lib/api/sandbox/shell";
import type { SandboxFunctionErrorCode } from "@app/lib/api/sandbox_functions/errors";
import { SandboxFunctionError } from "@app/lib/api/sandbox_functions/errors";
import {
splitStagingStdout,
stagingHashCaptureLines,
verifyStagingContent,
} from "@app/lib/api/sandbox_functions/staging_integrity";
import type { Authenticator } from "@app/lib/auth";
import type { SpaceResource } from "@app/lib/resources/space_resource";
import type { SandboxFunctionUserIdentityPolicy } from "@app/types/api/sandbox_functions";
Expand Down Expand Up @@ -101,6 +106,10 @@ export async function buildSandboxFunctionOnSandbox(
`mkdir -p -- ${shellEscape(buildDir)}`,
// `--` stops the model-supplied source path from being read as a dsbx flag.
`${DSBX_BIN_PATH} function build -- ${shellEscape(srcSandboxPath)} ${shellEscape(bundlePath)} ${shellEscape(schemaPath)}`,
// Pin the artifact hashes in the same exec; verified after the provider read-back
// below (the read-back runs as root and follows symlinks, so a swapped staging
// file would otherwise read an arbitrary root file).
...stagingHashCaptureLines([bundlePath, schemaPath]),
].join("\n");

const execResult = await sandbox.exec(auth, command, {
Expand All @@ -113,7 +122,8 @@ export async function buildSandboxFunctionOnSandbox(
);
}

const envelope = parseBuildEnvelope(execResult.value.stdout);
const { dsbxStdout, hashes } = splitStagingStdout(execResult.value.stdout);
const envelope = parseBuildEnvelope(dsbxStdout);
if (envelope.isErr()) {
return envelope;
}
Expand All @@ -129,12 +139,30 @@ export async function buildSandboxFunctionOnSandbox(
new SandboxFunctionError("internal", bundleResult.error.message)
);
}
const bundleIntegrity = verifyStagingContent(
bundlePath,
bundleResult.value,
hashes,
{ execStderr: execResult.value.stderr }
);
if (bundleIntegrity.isErr()) {
return bundleIntegrity;
}
const schemaResult = await sandbox.readFile(auth, schemaPath);
if (schemaResult.isErr()) {
return new Err(
new SandboxFunctionError("internal", schemaResult.error.message)
);
}
const schemaIntegrity = verifyStagingContent(
schemaPath,
schemaResult.value,
hashes,
{ execStderr: execResult.value.stderr }
);
if (schemaIntegrity.isErr()) {
return schemaIntegrity;
}

return parseSchemaFile(
schemaResult.value.toString("utf8"),
Expand Down
Loading
Loading