Skip to content
Closed
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
290 changes: 290 additions & 0 deletions nemoclaw/src/blueprint/verify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, it, expect, vi, beforeEach } from "vitest";
import { createHash } from "node:crypto";
import type { BlueprintManifest } from "./resolve.js";

// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------

vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
readdirSync: vi.fn(),
statSync: vi.fn(),
}));

const { readFileSync, readdirSync, statSync } = await import("node:fs");
const { verifyBlueprintDigest, checkCompatibility } = await import("./verify.js");

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

interface MockFile {
path: string;
content: string;
}

function expectedDigest(files: MockFile[]): string {
const hash = createHash("sha256");
const sorted = [...files].sort((a, b) =>
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
);
for (const f of sorted) {
hash.update(f.path);
hash.update(f.content);
}
return hash.digest("hex");
}

function mockDirectory(files: MockFile[]): void {
vi.mocked(readdirSync).mockReturnValue(
files.map((f) => f.path) as unknown as ReturnType<typeof readdirSync>,
);
vi.mocked(statSync).mockReturnValue({
isDirectory: () => false,
} as ReturnType<typeof statSync>);
vi.mocked(readFileSync).mockImplementation((filePath: unknown) => {
const p = String(filePath);
const file = files.find((f) => p.endsWith(f.path));
return Buffer.from(file?.content ?? "");
});
}

function makeManifest(overrides: Partial<BlueprintManifest> = {}): BlueprintManifest {
return {
version: "1.0.0",
minOpenShellVersion: "0.1.0",
minOpenClawVersion: "0.1.0",
profiles: ["default"],
digest: "placeholder",
...overrides,
};
}

// ---------------------------------------------------------------------------
// Setup
// ---------------------------------------------------------------------------

const MOCK_FILES: MockFile[] = [
{ path: "blueprint.yaml", content: "version: 1.0.0\ndigest: abc" },
{ path: "runner.py", content: "print('hello')" },
];

beforeEach(() => {
vi.resetAllMocks();
});

// ---------------------------------------------------------------------------
// verifyBlueprintDigest
// ---------------------------------------------------------------------------

describe("verifyBlueprintDigest", () => {
describe("happy path", () => {
it("returns valid: true when digest matches", () => {
mockDirectory(MOCK_FILES);
const digest = expectedDigest(MOCK_FILES);
const manifest = makeManifest({ digest });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
expect(result.actualDigest).toBe(digest);
expect(result.expectedDigest).toBe(digest);
});
});

describe("digest mismatch", () => {
it("returns valid: false when digest does not match", () => {
mockDirectory(MOCK_FILES);
const actual = expectedDigest(MOCK_FILES);
// A valid-format but wrong digest
const wrong = "a".repeat(64);
const manifest = makeManifest({ digest: wrong });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors).toHaveLength(1);
expect(result.errors[0]).toContain("Digest mismatch");
expect(result.errors[0]).toContain(wrong);
expect(result.errors[0]).toContain(actual);
});
});

describe("empty digest — bypass prevention", () => {
it("rejects empty string digest", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "" });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([
"Blueprint manifest is missing a digest — cannot verify integrity",
]);
});

it("rejects undefined digest", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: undefined as unknown as string });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([
"Blueprint manifest is missing a digest — cannot verify integrity",
]);
});

it("rejects whitespace-only digest", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: " " });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors).toEqual([
"Blueprint manifest is missing a digest — cannot verify integrity",
]);
});

it("skips directory hashing when digest is missing", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "" });

verifyBlueprintDigest("/fake/path", manifest);

expect(readdirSync).not.toHaveBeenCalled();
});
});

describe("digest format validation", () => {
it("rejects truncated hex string", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "abcdef1234" });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors[0]).toContain("format is invalid");
});

it("rejects uppercase hex characters", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "A".repeat(64) });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors[0]).toContain("format is invalid");
});

it("rejects non-hex characters", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "g".repeat(64) });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors[0]).toContain("format is invalid");
});

it("rejects digest with sha256: prefix", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "sha256:" + "a".repeat(64) });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(false);
expect(result.errors[0]).toContain("format is invalid");
});

it("skips directory hashing when format is invalid", () => {
mockDirectory(MOCK_FILES);
const manifest = makeManifest({ digest: "not-a-digest" });

verifyBlueprintDigest("/fake/path", manifest);

expect(readdirSync).not.toHaveBeenCalled();
});
});

describe("multi-file directory", () => {
it("computes digest across all files sorted by path", () => {
const unsortedFiles: MockFile[] = [
{ path: "c.txt", content: "third" },
{ path: "a.txt", content: "first" },
{ path: "b.txt", content: "second" },
];
mockDirectory(unsortedFiles);
const digest = expectedDigest(unsortedFiles);
const manifest = makeManifest({ digest });

const result = verifyBlueprintDigest("/fake/path", manifest);

expect(result.valid).toBe(true);
expect(result.actualDigest).toBe(digest);
});
});
});

// ---------------------------------------------------------------------------
// checkCompatibility
// ---------------------------------------------------------------------------

describe("checkCompatibility", () => {
it("returns no errors when all versions meet minimums", () => {
const manifest = makeManifest({
minOpenShellVersion: "1.0.0",
minOpenClawVersion: "2.0.0",
});

const errors = checkCompatibility(manifest, "1.2.0", "2.5.0");

expect(errors).toEqual([]);
});

it("returns no errors when actual equals minimum", () => {
const manifest = makeManifest({
minOpenShellVersion: "1.0.0",
minOpenClawVersion: "2.0.0",
});

const errors = checkCompatibility(manifest, "1.0.0", "2.0.0");

expect(errors).toEqual([]);
});

it("returns error when openshell version is too old", () => {
const manifest = makeManifest({ minOpenShellVersion: "1.0.0" });

const errors = checkCompatibility(manifest, "0.9.0", "99.0.0");

expect(errors).toHaveLength(1);
expect(errors[0]).toContain("OpenShell");
});

it("returns error when openclaw version is too old", () => {
const manifest = makeManifest({ minOpenClawVersion: "2.0.0" });

const errors = checkCompatibility(manifest, "99.0.0", "1.9.0");

expect(errors).toHaveLength(1);
expect(errors[0]).toContain("OpenClaw");
});

it("skips check when minimum version is empty", () => {
const manifest = makeManifest({
minOpenShellVersion: "",
minOpenClawVersion: "",
});

const errors = checkCompatibility(manifest, "0.0.1", "0.0.1");

expect(errors).toEqual([]);
});
});
32 changes: 27 additions & 5 deletions nemoclaw/src/blueprint/verify.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { createHash } from "node:crypto";
import { createHash, timingSafeEqual } from "node:crypto";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import type { BlueprintManifest } from "./resolve.js";

// SHA-256 hex digest: exactly 64 lowercase hex characters
const DIGEST_PATTERN = /^[a-f0-9]{64}$/;

export interface VerificationResult {
valid: boolean;
expectedDigest: string;
Expand All @@ -18,15 +21,27 @@ export function verifyBlueprintDigest(
manifest: BlueprintManifest,
): VerificationResult {
const errors: string[] = [];
const actualDigest = computeDirectoryDigest(blueprintPath);
let actualDigest = "";

if (manifest.digest && manifest.digest !== actualDigest) {
errors.push(`Digest mismatch: expected ${manifest.digest}, got ${actualDigest}`);
// A missing digest means "cannot verify", not "verification unnecessary".
// This is distinct from minOpenShellVersion/minOpenClawVersion where missing
// means "no requirement" and the falsy guard is correct.
if (!manifest.digest || !manifest.digest.trim()) {
errors.push("Blueprint manifest is missing a digest — cannot verify integrity");
} else if (!DIGEST_PATTERN.test(manifest.digest)) {
errors.push(
`Blueprint digest format is invalid: "${manifest.digest}". Expected: 64 lowercase hex characters (SHA-256)`,
);
} else {
actualDigest = computeDirectoryDigest(blueprintPath);
if (!digestsEqual(manifest.digest, actualDigest)) {
errors.push(`Digest mismatch: expected ${manifest.digest}, got ${actualDigest}`);
}
}

return {
valid: errors.length === 0,
expectedDigest: manifest.digest,
expectedDigest: manifest.digest ?? "",
actualDigest,
errors,
};
Expand Down Expand Up @@ -68,6 +83,13 @@ function satisfiesMinVersion(actual: string, minimum: string): boolean {
return true; // equal
}

function digestsEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a, "utf-8");
const bufB = Buffer.from(b, "utf-8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

function computeDirectoryDigest(dirPath: string): string {
const hash = createHash("sha256");
const files = collectFiles(dirPath).sort();
Expand Down