diff --git a/nemoclaw/src/blueprint/verify.test.ts b/nemoclaw/src/blueprint/verify.test.ts new file mode 100644 index 00000000000..c0e4cdfa428 --- /dev/null +++ b/nemoclaw/src/blueprint/verify.test.ts @@ -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, + ); + vi.mocked(statSync).mockReturnValue({ + isDirectory: () => false, + } as ReturnType); + 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 { + 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([]); + }); +}); diff --git a/nemoclaw/src/blueprint/verify.ts b/nemoclaw/src/blueprint/verify.ts index bd94b71ffeb..88bd29aefa0 100644 --- a/nemoclaw/src/blueprint/verify.ts +++ b/nemoclaw/src/blueprint/verify.ts @@ -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; @@ -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, }; @@ -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();