Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
50d0cc8
feat(cactus): add SHA-256 integrity helper for fetched assets
sroussey May 22, 2026
b088826
feat(cactus): extend catalog asset spec with sha256 + size
sroussey May 22, 2026
40efba1
feat(cactus): verify SHA-256 in fetchAssetBytes (Node)
sroussey May 22, 2026
b5dbf9c
feat(cactus): verify SHA-256 in fetchAssetBytes (browser)
sroussey May 22, 2026
7ab699a
feat(cactus): propagate integrity error message in Download (Node)
sroussey May 22, 2026
d40e160
feat(cactus): propagate integrity error message in Download (browser)
sroussey May 22, 2026
c5b17e0
test(cactus): integrity helper tests
sroussey May 22, 2026
78b5c72
fix(cactus): use path.relative for containment check (root-safe + Cod…
sroussey May 22, 2026
97e4710
fix(cactus): apply spec.size pre-check on Node disk-read cache-hit path
sroussey May 22, 2026
39143cb
fix(cactus): apply spec.size pre-check on browser Cache Storage hit path
sroussey May 22, 2026
37e1544
fix(cactus): reserve filename length for .tmp suffix in atomic write
sroussey May 22, 2026
64e9df6
fix(cactus): mirror tightened filename length cap in browser variant
sroussey May 22, 2026
947dd38
test(cactus): move integrity test to packages/test/ for CI discovery
sroussey May 22, 2026
c425255
test(cactus): add integrity test under packages/test/ for CI discovery
sroussey May 22, 2026
f53043b
test(cactus): expose Cactus_Integrity helpers via _testOnly
sroussey May 22, 2026
043057c
test(cactus): expose Cactus_Integrity helpers via _testOnly (browser)
sroussey May 22, 2026
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
121 changes: 121 additions & 0 deletions packages/test/src/test/ai-provider-cactus/Cactus_Integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/**
* @license
* Copyright 2026 Steven Roussey <sroussey@gmail.com>
* SPDX-License-Identifier: Apache-2.0
*/

import { _testOnly } from "@workglow/cactus/ai";
import { describe, expect, it } from "vitest";

const {
CACTUS_HASH_PLACEHOLDER,
CactusIntegrityError,
isHashPlaceholder,
sha256Hex,
verifySha256,
} = _testOnly;

// Known SHA-256 of the ASCII string "abc" — RFC 6234 test vector.
const SHA256_ABC = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";

function asciiBytes(s: string): Uint8Array {
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}

describe("sha256Hex", () => {
it('matches the known RFC 6234 vector for "abc"', async () => {
const hex = await sha256Hex(asciiBytes("abc"));
expect(hex).toBe(SHA256_ABC);
});

it("accepts ArrayBuffer input", async () => {
// `Uint8Array.prototype.buffer` is typed `ArrayBufferLike` in newer
// lib.dom.d.ts (it can be SharedArrayBuffer). Materialize a concrete
// ArrayBuffer so the test exercises that branch of sha256Hex's input.
const src = asciiBytes("abc");
const buf = new ArrayBuffer(src.byteLength);
new Uint8Array(buf).set(src);
const hex = await sha256Hex(buf);
expect(hex).toBe(SHA256_ABC);
});

it("produces 64 lowercase hex chars", async () => {
const hex = await sha256Hex(new Uint8Array([0]));
expect(hex).toMatch(/^[0-9a-f]{64}$/);
});
});

describe("verifySha256", () => {
const ctx = { url: "https://example/asset", filename: "asset.bin" };

it("passes when the digest matches", async () => {
await expect(verifySha256(asciiBytes("abc"), SHA256_ABC, ctx)).resolves.toBeUndefined();
});

it("accepts uppercase expected hex (normalized to lowercase)", async () => {
await expect(
verifySha256(asciiBytes("abc"), SHA256_ABC.toUpperCase(), ctx)
).resolves.toBeUndefined();
});

it("throws CactusIntegrityError when the digest does not match", async () => {
const wrong = "0".repeat(64);
await expect(verifySha256(asciiBytes("abc"), wrong, ctx)).rejects.toBeInstanceOf(
CactusIntegrityError
);
});

it("throws plain Error when expected hash is too short", async () => {
await expect(verifySha256(asciiBytes("abc"), "a".repeat(63), ctx)).rejects.toThrow(
/Invalid catalog SHA-256/
);
});

it("throws plain Error when expected hash is too long", async () => {
await expect(verifySha256(asciiBytes("abc"), "a".repeat(65), ctx)).rejects.toThrow(
/Invalid catalog SHA-256/
);
});

it("throws plain Error when expected hash contains non-hex characters", async () => {
const bad = "z" + "a".repeat(63);
await expect(verifySha256(asciiBytes("abc"), bad, ctx)).rejects.toThrow(
/non-hex characters/
);
});

it("skips verification when expected is the placeholder sentinel", async () => {
await expect(
verifySha256(asciiBytes("garbage"), CACTUS_HASH_PLACEHOLDER, ctx)
).resolves.toBeUndefined();
});
});

describe("isHashPlaceholder", () => {
it("recognizes the placeholder", () => {
expect(isHashPlaceholder(CACTUS_HASH_PLACEHOLDER)).toBe(true);
});

it("rejects real-looking hashes", () => {
expect(isHashPlaceholder(SHA256_ABC)).toBe(false);
});
});

describe("CactusIntegrityError", () => {
it("carries url, filename, expected, actual on the instance", () => {
const err = new CactusIntegrityError({
url: "u",
filename: "f",
expected: "e",
actual: "a",
});
expect(err.name).toBe("CactusIntegrityError");
expect(err.url).toBe("u");
expect(err.filename).toBe("f");
expect(err.expected).toBe("e");
expect(err.actual).toBe("a");
expect(err.message).toMatch(/Integrity check failed for f/);
});
});
18 changes: 18 additions & 0 deletions providers/cactus/src/ai.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export * from "./ai/registerCactus.browser";

import { CactusQueuedProvider } from "./ai/CactusQueuedProvider.browser";
import { CACTUS_RUN_FN_SPECS } from "./ai/common/Cactus_Capabilities";
import {
CACTUS_HASH_PLACEHOLDER,
CactusIntegrityError,
isHashPlaceholder,
sha256Hex,
verifySha256,
} from "./ai/common/Cactus_Integrity";
import { CACTUS_RUN_FNS } from "./ai/common/Cactus_JobRunFns.browser";
import { cactusConfigJson, cactusEngines } from "./ai/common/Cactus_Runtime.browser";

Expand All @@ -33,11 +40,22 @@ import { cactusConfigJson, cactusEngines } from "./ai/common/Cactus_Runtime.brow
* and their runtime state copies are distinct module instances. Reading
* the runtime state via `_testOnly` (rather than `@workglow/cactus/ai-runtime`)
* guarantees the test observes the same Map that the run-fns mutate.
*
* The `Cactus_Integrity` symbols (sha256Hex, verifySha256, CactusIntegrityError,
* isHashPlaceholder, CACTUS_HASH_PLACEHOLDER) are pure/stateless helpers exposed
* here for unit testing only. They are not part of the stable public API; depend
* on the catalog's `sha256` field instead.
*/
export const _testOnly = {
CactusQueuedProvider,
CACTUS_RUN_FN_SPECS,
CACTUS_RUN_FNS,
cactusEngines,
cactusConfigJson,
// Integrity helpers (test-only):
CACTUS_HASH_PLACEHOLDER,
CactusIntegrityError,
isHashPlaceholder,
sha256Hex,
verifySha256,
} as const;
18 changes: 18 additions & 0 deletions providers/cactus/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export * from "./ai/registerCactus";

import { CactusQueuedProvider } from "./ai/CactusQueuedProvider";
import { CACTUS_RUN_FN_SPECS } from "./ai/common/Cactus_Capabilities";
import {
CACTUS_HASH_PLACEHOLDER,
CactusIntegrityError,
isHashPlaceholder,
sha256Hex,
verifySha256,
} from "./ai/common/Cactus_Integrity";
import { CACTUS_RUN_FNS } from "./ai/common/Cactus_JobRunFns";
import { cactusConfigJson, cactusEngines } from "./ai/common/Cactus_Runtime";

Expand All @@ -33,11 +40,22 @@ import { cactusConfigJson, cactusEngines } from "./ai/common/Cactus_Runtime";
* — their copies of `Cactus_Runtime.ts` are distinct module instances. Reading
* the runtime state via `_testOnly` (rather than `@workglow/cactus/ai-runtime`)
* guarantees the test observes the same Map that the run-fns mutate.
*
* The `Cactus_Integrity` symbols (sha256Hex, verifySha256, CactusIntegrityError,
* isHashPlaceholder, CACTUS_HASH_PLACEHOLDER) are pure/stateless helpers exposed
* here for unit testing only. They are not part of the stable public API; depend
* on the catalog's `sha256` field instead.
*/
export const _testOnly = {
CactusQueuedProvider,
CACTUS_RUN_FN_SPECS,
CACTUS_RUN_FNS,
cactusEngines,
cactusConfigJson,
// Integrity helpers (test-only):
CACTUS_HASH_PLACEHOLDER,
CactusIntegrityError,
isHashPlaceholder,
sha256Hex,
verifySha256,
} as const;
28 changes: 22 additions & 6 deletions providers/cactus/src/ai/common/Cactus_Download.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
ModelDownloadTaskRunInput,
ModelDownloadTaskRunOutput,
} from "@workglow/ai";
import { getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import { CactusIntegrityError } from "./Cactus_Integrity";
import { assetSpecsOf, getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import type { CactusModelConfig } from "./Cactus_ModelSchema";
import { fetchAssetBytes, markModelCached } from "./Cactus_Runtime.browser";

Expand All @@ -23,14 +24,29 @@ export const Cactus_Download: AiProviderRunFn<
const entry = getCactusCatalogEntry(model_id);
if (!entry) throw new Error(`Unknown Cactus model_id: ${model_id}`);

const assets = [entry.assets.weights, entry.assets.vocab, entry.assets.config];
for (let i = 0; i < assets.length; i++) {
const specs = assetSpecsOf(entry);
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
emit({
type: "phase",
message: `Downloading ${assets[i]}`,
progress: Math.round(((i + 0.5) / assets.length) * 99),
message: `Downloading ${spec.filename}`,
progress: Math.round(((i + 0.5) / specs.length) * 99),
});
await fetchAssetBytes(model, assets[i]);
try {
await fetchAssetBytes(model, spec);
} catch (err) {
// Surface whatever the integrity layer phrased — it knows whether the
// mismatch was a SHA-256 digest or a byte-length pre-check, and the
// error message is already shaped correctly for both.
// StreamPhase.progress is required (number | undefined); pass undefined
// on the error path because there is no meaningful percentage to report.
emit({
type: "phase",
message: err instanceof CactusIntegrityError ? err.message : String(err),
progress: undefined,
});
throw err;
}
}
markModelCached(model_id);
emit({ type: "finish", data: { model: input.model! } });
Expand Down
28 changes: 22 additions & 6 deletions providers/cactus/src/ai/common/Cactus_Download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type {
ModelDownloadTaskRunInput,
ModelDownloadTaskRunOutput,
} from "@workglow/ai";
import { getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import { CactusIntegrityError } from "./Cactus_Integrity";
import { assetSpecsOf, getCactusCatalogEntry } from "./Cactus_ModelCatalog";
import type { CactusModelConfig } from "./Cactus_ModelSchema";
import { fetchAssetBytes, markModelCached } from "./Cactus_Runtime";

Expand All @@ -23,14 +24,29 @@ export const Cactus_Download: AiProviderRunFn<
const entry = getCactusCatalogEntry(model_id);
if (!entry) throw new Error(`Unknown Cactus model_id: ${model_id}`);

const assets = [entry.assets.weights, entry.assets.vocab, entry.assets.config];
for (let i = 0; i < assets.length; i++) {
const specs = assetSpecsOf(entry);
for (let i = 0; i < specs.length; i++) {
const spec = specs[i];
emit({
type: "phase",
message: `Downloading ${assets[i]}`,
progress: Math.round(((i + 0.5) / assets.length) * 99),
message: `Downloading ${spec.filename}`,
progress: Math.round(((i + 0.5) / specs.length) * 99),
});
await fetchAssetBytes(model, assets[i]);
try {
await fetchAssetBytes(model, spec);
} catch (err) {
// Surface whatever the integrity layer phrased — it knows whether the
// mismatch was a SHA-256 digest or a byte-length pre-check, and the
// error message is already shaped correctly for both.
// StreamPhase.progress is required (number | undefined); pass undefined
// on the error path because there is no meaningful percentage to report.
emit({
type: "phase",
message: err instanceof CactusIntegrityError ? err.message : String(err),
progress: undefined,
});
throw err;
}
}
markModelCached(model_id);
emit({ type: "finish", data: { model: input.model! } });
Expand Down
Loading
Loading