mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Bumps every version surface to 2.2.0, fixes a latent version-coupling bug in the OCR runtime tests, and stops an absent GPU runner from silently stalling a release. Version surfaces: scripts/sync-version.sh covers the 11 workspaces, APP_VERSION, and the docs release commands across all locales. Root package.json plus the three surfaces the script never reaches are done by hand: the DOCKERHUB.md banner and tag table, the docker-tags.md pinning table in 21 locales, and the example runtimeVersion in tools/image/ocr.md in 21 locales. The release-notes archive step is deliberately not pre-run, so the notes text stays editable until the release. Latent bug: runtime-state rejects any runtime whose compatibility.snapotterVersion is not exactly APP_VERSION, and five fixtures pinned the literal 2.1.0. Since semantic-release rewrites APP_VERSION on every release, the first PR after any bump would have gone red for a reason nobody would trace to the release. The fixtures now derive from APP_VERSION. GPU runner: sign-ocr-index needs verify-ocr-nvidia on self-hosted hardware, and the gated manifest job needs ai-bundles, so a missing runner queued instead of failing and produced no image tags. preflight-gpu-runner claims the same labels with no dependencies, so it is scheduled first and validates the GPU before the 90-minute build. An API preflight is impossible because listing self-hosted runners needs Administration:read, which GITHUB_TOKEN cannot hold, so RELEASE.md carries the maintainer-side check.
911 lines
29 KiB
TypeScript
911 lines
29 KiB
TypeScript
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
|
import {
|
|
chmodSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { dirname, join } from "node:path";
|
|
import { APP_VERSION } from "@snapotter/shared";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
getOcrRuntimeCapability,
|
|
readActiveRuntime,
|
|
readCommittedOcrRuntimeActivationIdentity,
|
|
readPendingOcrRuntimeForHandoff,
|
|
resolveAiDataDir,
|
|
selectOcrRuntimeTarget,
|
|
} from "../../../packages/ai/src/runtime-state.js";
|
|
|
|
// runtime-state rejects any runtime whose compatibility.snapotterVersion is not
|
|
// exactly APP_VERSION, so these fixtures must track the real version. A literal
|
|
// here passes only until the next release bump, then fails every assertion that
|
|
// resolves a runtime. That is what happened on 2.1.0 -> 2.2.0.
|
|
|
|
const temporaryDirectories: string[] = [];
|
|
const runtimeSigningKey = generateKeyPairSync("ed25519");
|
|
|
|
function sortJson(value: unknown): unknown {
|
|
if (Array.isArray(value)) return value.map(sortJson);
|
|
if (typeof value !== "object" || value === null) return value;
|
|
return Object.fromEntries(
|
|
Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, entry]) => [key, sortJson(entry)]),
|
|
);
|
|
}
|
|
|
|
function canonicalJson(value: unknown): string {
|
|
return `${JSON.stringify(sortJson(value))}\n`;
|
|
}
|
|
|
|
interface MutableDescriptor {
|
|
schemaVersion: unknown;
|
|
family: unknown;
|
|
generation: unknown;
|
|
status: unknown;
|
|
activatedAt: unknown;
|
|
artifact: Record<string, unknown>;
|
|
runtime: Record<string, unknown>;
|
|
compatibility: Record<string, unknown>;
|
|
capabilities: Record<string, unknown>;
|
|
health: Record<string, unknown>;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("resolveAiDataDir", () => {
|
|
it("keeps DATA_DIR authoritative over the verifier-only AI_DATA_DIR seam", () => {
|
|
const dataDir = join(tmpdir(), "snapotter-data-root");
|
|
vi.stubEnv("DATA_DIR", dataDir);
|
|
vi.stubEnv("AI_DATA_DIR", join(tmpdir(), "snapotter-verifier-ai-root"));
|
|
|
|
expect(resolveAiDataDir()).toBe(join(dataDir, "ai"));
|
|
});
|
|
});
|
|
|
|
function createRuntimeFixture(): {
|
|
aiDataDir: string;
|
|
activationPath: string;
|
|
descriptorPath: string;
|
|
runtimeRoot: string;
|
|
pythonPath: string;
|
|
entrypoint: string;
|
|
adapterPath: string;
|
|
smallModelPath: string;
|
|
mediumModelPath: string;
|
|
sitePackagePath: string;
|
|
signedIndexPath: string;
|
|
} {
|
|
const aiDataDir = mkdtempSync(join(tmpdir(), "snapotter-runtime-state-"));
|
|
temporaryDirectories.push(aiDataDir);
|
|
|
|
const v3Root = join(aiDataDir, "v3");
|
|
const runtimeRoot = join(v3Root, "runtimes", "ocr", "linux-amd64-cpu-py312", "generation-test");
|
|
const pythonPath = join(runtimeRoot, "venv", "bin", "python");
|
|
const entrypoint = join(runtimeRoot, "ocr_runner.py");
|
|
const adapterPath = join(runtimeRoot, "ocr_runtime.py");
|
|
const smallModelPath = join(runtimeRoot, "models", "small.onnx");
|
|
const mediumModelPath = join(runtimeRoot, "models", "medium.onnx");
|
|
const sitePackagePath = join(
|
|
runtimeRoot,
|
|
"venv",
|
|
"lib",
|
|
"python3.12",
|
|
"site-packages",
|
|
"rapidocr",
|
|
"__init__.py",
|
|
);
|
|
const descriptorPath = join(v3Root, "active", "ocr.json");
|
|
|
|
mkdirSync(join(runtimeRoot, "venv", "bin"), { recursive: true });
|
|
mkdirSync(join(runtimeRoot, "models"), { recursive: true });
|
|
mkdirSync(join(sitePackagePath, ".."), { recursive: true });
|
|
mkdirSync(join(v3Root, "active"), { recursive: true });
|
|
writeFileSync(pythonPath, "#!/bin/sh\n", "utf-8");
|
|
writeFileSync(entrypoint, "# test entrypoint\n", "utf-8");
|
|
writeFileSync(adapterPath, "# test adapter\n", "utf-8");
|
|
writeFileSync(smallModelPath, "small", "utf-8");
|
|
writeFileSync(mediumModelPath, "medium", "utf-8");
|
|
writeFileSync(sitePackagePath, "rapidocr-v1\n", "utf-8");
|
|
chmodSync(pythonPath, 0o755);
|
|
|
|
const files = [
|
|
["venv/bin/python", "#!/bin/sh\n", 0o755],
|
|
["ocr_runner.py", "# test entrypoint\n", 0o644],
|
|
["ocr_runtime.py", "# test adapter\n", 0o644],
|
|
["models/small.onnx", "small", 0o644],
|
|
["models/medium.onnx", "medium", 0o644],
|
|
["venv/lib/python3.12/site-packages/rapidocr/__init__.py", "rapidocr-v1\n", 0o644],
|
|
].map(([path, contents, mode]) => ({
|
|
path,
|
|
sha256: createHash("sha256")
|
|
.update(contents as string)
|
|
.digest("hex"),
|
|
size: Buffer.byteLength(contents as string),
|
|
mode,
|
|
}));
|
|
const artifact = {
|
|
family: "ocr",
|
|
target: "linux-amd64-cpu-py312",
|
|
generation: "generation-test",
|
|
version: APP_VERSION,
|
|
platform: "linux",
|
|
arch: "amd64",
|
|
archive: {
|
|
file: "ocr-linux-amd64-cpu-py312.tar.gz",
|
|
sha256: "a".repeat(64),
|
|
size: 123,
|
|
expandedSize: files.reduce((total, file) => total + file.size, 0),
|
|
},
|
|
files,
|
|
runtime: {
|
|
pythonPath: "venv/bin/python",
|
|
entrypoint: "ocr_runner.py",
|
|
adapterPath: "ocr_runtime.py",
|
|
},
|
|
models: {
|
|
"pp-ocrv6-small": createHash("sha256").update("small").digest("hex"),
|
|
"pp-ocrv6-medium": createHash("sha256").update("medium").digest("hex"),
|
|
},
|
|
compatibility: { protocolVersion: 1, snapotterVersion: APP_VERSION },
|
|
capabilities: {
|
|
qualities: ["balanced", "best"],
|
|
providers: ["CPUExecutionProvider"],
|
|
},
|
|
resources: { minimumMemoryBytes: 4 * 1024 ** 3 },
|
|
};
|
|
const unsignedIndex = { schemaVersion: 1, artifacts: [artifact] };
|
|
const signedIndex = {
|
|
...unsignedIndex,
|
|
signature: {
|
|
keyId: "runtime-state-test-key",
|
|
algorithm: "ed25519",
|
|
value: sign(
|
|
null,
|
|
Buffer.from(canonicalJson(unsignedIndex)),
|
|
runtimeSigningKey.privateKey,
|
|
).toString("base64"),
|
|
},
|
|
};
|
|
const signedIndexBytes = canonicalJson(signedIndex);
|
|
const signedIndexSha256 = createHash("sha256").update(signedIndexBytes).digest("hex");
|
|
const signedIndexPath = join(v3Root, "indexes", `${signedIndexSha256}.json`);
|
|
mkdirSync(join(v3Root, "indexes"), { recursive: true });
|
|
writeFileSync(signedIndexPath, signedIndexBytes, "utf-8");
|
|
vi.stubEnv("OCR_RUNTIME_INDEX_KEY_ID", "runtime-state-test-key");
|
|
vi.stubEnv(
|
|
"OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64",
|
|
Buffer.from(runtimeSigningKey.publicKey.export({ type: "spki", format: "pem" })).toString(
|
|
"base64",
|
|
),
|
|
);
|
|
|
|
const descriptorBytes = JSON.stringify({
|
|
schemaVersion: 1,
|
|
family: "ocr",
|
|
generation: "generation-test",
|
|
status: "ready",
|
|
activatedAt: "2026-07-13T00:00:00.000Z",
|
|
artifact: {
|
|
version: APP_VERSION,
|
|
target: "linux-amd64-cpu-py312",
|
|
platform: "linux",
|
|
arch: "amd64",
|
|
sha256: "a".repeat(64),
|
|
models: {
|
|
"pp-ocrv6-small": createHash("sha256").update("small").digest("hex"),
|
|
"pp-ocrv6-medium": createHash("sha256").update("medium").digest("hex"),
|
|
},
|
|
modelFiles: {
|
|
"pp-ocrv6-small": {
|
|
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/models/small.onnx",
|
|
sha256: createHash("sha256").update("small").digest("hex"),
|
|
size: 5,
|
|
},
|
|
"pp-ocrv6-medium": {
|
|
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/models/medium.onnx",
|
|
sha256: createHash("sha256").update("medium").digest("hex"),
|
|
size: 6,
|
|
},
|
|
},
|
|
signedIndex: {
|
|
path: `indexes/${signedIndexSha256}.json`,
|
|
sha256: signedIndexSha256,
|
|
size: Buffer.byteLength(signedIndexBytes),
|
|
},
|
|
},
|
|
runtime: {
|
|
pythonPath: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/venv/bin/python",
|
|
entrypoint: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runner.py",
|
|
integrityFiles: {
|
|
python: {
|
|
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/venv/bin/python",
|
|
sha256: createHash("sha256").update("#!/bin/sh\n").digest("hex"),
|
|
size: 10,
|
|
},
|
|
entrypoint: {
|
|
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runner.py",
|
|
sha256: createHash("sha256").update("# test entrypoint\n").digest("hex"),
|
|
size: 18,
|
|
},
|
|
adapter: {
|
|
path: "runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runtime.py",
|
|
sha256: createHash("sha256").update("# test adapter\n").digest("hex"),
|
|
size: 15,
|
|
},
|
|
},
|
|
},
|
|
compatibility: {
|
|
protocolVersion: 1,
|
|
snapotterVersion: APP_VERSION,
|
|
},
|
|
capabilities: {
|
|
qualities: ["balanced", "best"],
|
|
providers: ["CPUExecutionProvider"],
|
|
},
|
|
health: {
|
|
status: "healthy",
|
|
checkedAt: "2026-07-13T00:00:01.000Z",
|
|
},
|
|
});
|
|
writeFileSync(descriptorPath, descriptorBytes, "utf-8");
|
|
const activationPath = join(v3Root, "rollback", "ocr.json");
|
|
mkdirSync(join(v3Root, "rollback"), { recursive: true });
|
|
writeFileSync(
|
|
activationPath,
|
|
canonicalJson({
|
|
schemaVersion: 1,
|
|
family: "ocr",
|
|
status: "committed",
|
|
activatedGeneration: "generation-test",
|
|
activatedDescriptorSha256: createHash("sha256").update(descriptorBytes).digest("hex"),
|
|
previousDescriptorB64: null,
|
|
previousGeneration: null,
|
|
previousIndexPath: null,
|
|
}),
|
|
"utf-8",
|
|
);
|
|
|
|
return {
|
|
aiDataDir,
|
|
activationPath,
|
|
descriptorPath,
|
|
runtimeRoot,
|
|
pythonPath,
|
|
entrypoint,
|
|
adapterPath,
|
|
smallModelPath,
|
|
mediumModelPath,
|
|
sitePackagePath,
|
|
signedIndexPath,
|
|
};
|
|
}
|
|
|
|
function mutateDescriptor(
|
|
descriptorPath: string,
|
|
mutate: (descriptor: MutableDescriptor) => void,
|
|
): void {
|
|
const descriptor = JSON.parse(readFileSync(descriptorPath, "utf-8")) as MutableDescriptor;
|
|
mutate(descriptor);
|
|
const descriptorBytes = JSON.stringify(descriptor);
|
|
writeFileSync(descriptorPath, descriptorBytes, "utf-8");
|
|
const activationPath = join(dirname(dirname(descriptorPath)), "rollback", "ocr.json");
|
|
const activation = JSON.parse(readFileSync(activationPath, "utf-8")) as Record<string, unknown>;
|
|
activation.activatedDescriptorSha256 = createHash("sha256").update(descriptorBytes).digest("hex");
|
|
writeFileSync(activationPath, canonicalJson(activation), "utf-8");
|
|
}
|
|
|
|
function mutateActivationState(
|
|
activationPath: string,
|
|
mutate: (state: Record<string, unknown>) => void,
|
|
): void {
|
|
const state = JSON.parse(readFileSync(activationPath, "utf-8")) as Record<string, unknown>;
|
|
mutate(state);
|
|
writeFileSync(activationPath, canonicalJson(state), "utf-8");
|
|
}
|
|
|
|
const invalidDescriptorCases: Array<[string, (descriptor: MutableDescriptor) => void]> = [
|
|
["schema version", (descriptor) => (descriptor.schemaVersion = 2)],
|
|
["family", (descriptor) => (descriptor.family = "speech")],
|
|
["generation", (descriptor) => (descriptor.generation = "")],
|
|
["activation status", (descriptor) => (descriptor.status = "staging")],
|
|
["activation timestamp", (descriptor) => (descriptor.activatedAt = "soon")],
|
|
["artifact version", (descriptor) => (descriptor.artifact.version = "")],
|
|
["artifact digest", (descriptor) => (descriptor.artifact.sha256 = "abc")],
|
|
["signed index", (descriptor) => (descriptor.artifact.signedIndex = {})],
|
|
["artifact target", (descriptor) => (descriptor.artifact.target = "linux-amd64-gpu")],
|
|
["model digests", (descriptor) => (descriptor.artifact.models = {})],
|
|
["model files", (descriptor) => (descriptor.artifact.modelFiles = {})],
|
|
["runtime integrity files", (descriptor) => (descriptor.runtime.integrityFiles = {})],
|
|
[
|
|
"model file location",
|
|
(descriptor) => {
|
|
const files = descriptor.artifact.modelFiles as Record<string, Record<string, unknown>>;
|
|
files["pp-ocrv6-small"].path =
|
|
"runtimes/ocr/linux-amd64-cpu-py312/generation-test/ocr_runner.py";
|
|
},
|
|
],
|
|
["protocol version", (descriptor) => (descriptor.compatibility.protocolVersion = 2)],
|
|
["SnapOtter version", (descriptor) => (descriptor.compatibility.snapotterVersion = "1.0.0")],
|
|
["health status", (descriptor) => (descriptor.health.status = "degraded")],
|
|
["health timestamp", (descriptor) => (descriptor.health.checkedAt = "never")],
|
|
["qualities", (descriptor) => (descriptor.capabilities.qualities = ["best"])],
|
|
["providers", (descriptor) => (descriptor.capabilities.providers = [])],
|
|
];
|
|
|
|
describe("selectOcrRuntimeTarget", () => {
|
|
it("selects the Python 3.12 CPU artifact for Linux AMD64", () => {
|
|
expect(selectOcrRuntimeTarget({ platform: "linux", arch: "x64" })).toBe(
|
|
"linux-amd64-cpu-py312",
|
|
);
|
|
});
|
|
|
|
it("selects the Python 3.11 CPU artifact for Linux ARM64", () => {
|
|
expect(selectOcrRuntimeTarget({ platform: "linux", arch: "arm64" })).toBe(
|
|
"linux-arm64-cpu-py311",
|
|
);
|
|
});
|
|
|
|
it("fails closed on unsupported platforms and architectures", () => {
|
|
expect(selectOcrRuntimeTarget({ platform: "darwin", arch: "arm64" })).toBeNull();
|
|
expect(selectOcrRuntimeTarget({ platform: "linux", arch: "ia32" })).toBeNull();
|
|
});
|
|
|
|
it("rejects accurate runtimes outside the official container ABI", () => {
|
|
expect(
|
|
selectOcrRuntimeTarget({
|
|
platform: "linux",
|
|
arch: "x64",
|
|
officialContainer: false,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("readActiveRuntime", () => {
|
|
it("returns a healthy compatible descriptor with contained absolute runtime paths", () => {
|
|
const fixture = createRuntimeFixture();
|
|
|
|
const descriptor = readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
});
|
|
|
|
expect(descriptor).toMatchObject({
|
|
schemaVersion: 1,
|
|
family: "ocr",
|
|
generation: "generation-test",
|
|
status: "ready",
|
|
artifact: {
|
|
target: "linux-amd64-cpu-py312",
|
|
platform: "linux",
|
|
arch: "amd64",
|
|
models: {
|
|
"pp-ocrv6-small": createHash("sha256").update("small").digest("hex"),
|
|
"pp-ocrv6-medium": createHash("sha256").update("medium").digest("hex"),
|
|
},
|
|
},
|
|
compatibility: {
|
|
protocolVersion: 1,
|
|
snapotterVersion: APP_VERSION,
|
|
},
|
|
runtime: {
|
|
root: fixture.runtimeRoot,
|
|
pythonPath: fixture.pythonPath,
|
|
entrypoint: fixture.entrypoint,
|
|
integrityFiles: expect.any(Object),
|
|
},
|
|
capabilities: {
|
|
qualities: ["balanced", "best"],
|
|
providers: ["CPUExecutionProvider"],
|
|
},
|
|
health: { status: "healthy" },
|
|
});
|
|
});
|
|
|
|
it("routes only committed state while installer handoff can inspect exact pending state", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const options = {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux" as const,
|
|
arch: "x64" as const,
|
|
};
|
|
mutateActivationState(fixture.activationPath, (state) => {
|
|
state.status = "pending";
|
|
});
|
|
|
|
expect(readActiveRuntime("ocr", options)).toBeNull();
|
|
expect(readPendingOcrRuntimeForHandoff(options)).toMatchObject({
|
|
generation: "generation-test",
|
|
});
|
|
});
|
|
|
|
it("accepts a committed upgrade marker with a structurally valid previous descriptor", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const previous = JSON.parse(readFileSync(fixture.descriptorPath, "utf-8"));
|
|
previous.generation = "generation-previous";
|
|
const previousBytes = canonicalJson(previous);
|
|
mutateActivationState(fixture.activationPath, (state) => {
|
|
state.previousDescriptorB64 = Buffer.from(previousBytes).toString("base64");
|
|
state.previousGeneration = "generation-previous";
|
|
state.previousIndexPath = previous.artifact.signedIndex.path;
|
|
});
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toMatchObject({ generation: "generation-test" });
|
|
});
|
|
|
|
it("fails closed for missing, corrupt, symlinked, or descriptor-mismatched activation state", () => {
|
|
const fixtures = Array.from({ length: 4 }, () => createRuntimeFixture());
|
|
const options = (aiDataDir: string) => ({
|
|
aiDataDir,
|
|
platform: "linux" as const,
|
|
arch: "x64" as const,
|
|
});
|
|
|
|
unlinkSync(fixtures[0].activationPath);
|
|
writeFileSync(fixtures[1].activationPath, "{{not-json", "utf-8");
|
|
const external = join(fixtures[2].aiDataDir, "external-activation.json");
|
|
writeFileSync(external, readFileSync(fixtures[2].activationPath));
|
|
unlinkSync(fixtures[2].activationPath);
|
|
symlinkSync(external, fixtures[2].activationPath);
|
|
mutateActivationState(fixtures[3].activationPath, (state) => {
|
|
state.activatedDescriptorSha256 = "f".repeat(64);
|
|
});
|
|
|
|
for (const fixture of fixtures) {
|
|
expect(readActiveRuntime("ocr", options(fixture.aiDataDir))).toBeNull();
|
|
expect(readPendingOcrRuntimeForHandoff(options(fixture.aiDataDir))).toBeNull();
|
|
expect(getOcrRuntimeCapability(options(fixture.aiDataDir))).toMatchObject({
|
|
available: false,
|
|
status: "invalid",
|
|
reason: "descriptor-invalid",
|
|
});
|
|
}
|
|
});
|
|
|
|
it("rejects a structurally corrupt activation record even when its active hash matches", () => {
|
|
const fixture = createRuntimeFixture();
|
|
mutateActivationState(fixture.activationPath, (state) => {
|
|
state.previousDescriptorB64 = "not-base64***";
|
|
});
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each(invalidDescriptorCases)("fails closed for an invalid %s", (_name, mutate) => {
|
|
const fixture = createRuntimeFixture();
|
|
mutateDescriptor(fixture.descriptorPath, mutate);
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("fails closed when the artifact is incompatible with the current host", () => {
|
|
const fixture = createRuntimeFixture();
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "arm64",
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "darwin",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects absolute paths and traversal even when they point to files", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const outsidePath = join(fixture.aiDataDir, "outside-python");
|
|
writeFileSync(outsidePath, "#!/bin/sh\n", "utf-8");
|
|
|
|
mutateDescriptor(fixture.descriptorPath, (descriptor) => {
|
|
descriptor.runtime.pythonPath = outsidePath;
|
|
});
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
|
|
mutateDescriptor(fixture.descriptorPath, (descriptor) => {
|
|
descriptor.runtime.pythonPath = "../outside-python";
|
|
});
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects missing files and symlinks in runtime paths", () => {
|
|
const fixture = createRuntimeFixture();
|
|
unlinkSync(fixture.pythonPath);
|
|
symlinkSync(fixture.entrypoint, fixture.pythonPath);
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
|
|
unlinkSync(fixture.pythonPath);
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects missing, resized, and symlinked model files", () => {
|
|
const missing = createRuntimeFixture();
|
|
unlinkSync(missing.smallModelPath);
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: missing.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
|
|
const resized = createRuntimeFixture();
|
|
writeFileSync(resized.mediumModelPath, "changed", "utf-8");
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: resized.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
|
|
const symlinked = createRuntimeFixture();
|
|
unlinkSync(symlinked.smallModelPath);
|
|
symlinkSync(symlinked.mediumModelPath, symlinked.smallModelPath);
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: symlinked.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects same-size model and execution-code corruption", () => {
|
|
const model = createRuntimeFixture();
|
|
writeFileSync(model.smallModelPath, "wrong", "utf-8");
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: model.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
|
|
const adapter = createRuntimeFixture();
|
|
writeFileSync(adapter.adapterPath, "# evil adapter\n", "utf-8");
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: adapter.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rechecks filesystem identity and rejects same-size site-packages corruption", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const options = {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux" as const,
|
|
arch: "x64" as const,
|
|
};
|
|
|
|
expect(readActiveRuntime("ocr", options)).not.toBeNull();
|
|
writeFileSync(fixture.sitePackagePath, "rapidocr-v2\n", "utf-8");
|
|
|
|
expect(readActiveRuntime("ocr", options)).toBeNull();
|
|
});
|
|
|
|
it("rejects payload metadata that no longer matches its trusted signed index", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const index = JSON.parse(readFileSync(fixture.signedIndexPath, "utf-8"));
|
|
index.artifacts[0].files.at(-1).sha256 = "f".repeat(64);
|
|
writeFileSync(fixture.signedIndexPath, canonicalJson(index), "utf-8");
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("ignores missing, corrupt, non-canonical, and legacy state", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const activeDir = join(fixture.aiDataDir, "v3", "active");
|
|
const aiDataDir = fixture.aiDataDir;
|
|
const options = { aiDataDir, platform: "linux" as const, arch: "x64" as const };
|
|
const validJson = readFileSync(fixture.descriptorPath, "utf-8");
|
|
|
|
unlinkSync(fixture.descriptorPath);
|
|
writeFileSync(join(aiDataDir, "installed.json"), validJson, "utf-8");
|
|
writeFileSync(join(activeDir, "ocr-onnx.json"), validJson, "utf-8");
|
|
expect(readActiveRuntime("ocr", options)).toBeNull();
|
|
|
|
writeFileSync(fixture.descriptorPath, "{{not-json", "utf-8");
|
|
expect(readActiveRuntime("ocr", options)).toBeNull();
|
|
});
|
|
|
|
it("rejects a caller-controlled family path", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const nonCanonicalPath = join(fixture.aiDataDir, "v3", "other.json");
|
|
writeFileSync(nonCanonicalPath, readFileSync(fixture.descriptorPath, "utf-8"), "utf-8");
|
|
|
|
expect(
|
|
readActiveRuntime("../other" as "ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("rejects a symlinked active directory", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const activeDir = join(fixture.aiDataDir, "v3", "active");
|
|
const externalActiveDir = join(fixture.aiDataDir, "external-active");
|
|
const descriptorJson = readFileSync(fixture.descriptorPath, "utf-8");
|
|
|
|
rmSync(activeDir, { recursive: true });
|
|
mkdirSync(externalActiveDir);
|
|
writeFileSync(join(externalActiveDir, "ocr.json"), descriptorJson, "utf-8");
|
|
symlinkSync(externalActiveDir, activeDir, "dir");
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("resolves the AI root from AI_DATA_DIR", () => {
|
|
const fixture = createRuntimeFixture();
|
|
vi.stubEnv("DATA_DIR", "");
|
|
vi.stubEnv("AI_DATA_DIR", fixture.aiDataDir);
|
|
|
|
expect(readActiveRuntime("ocr", { platform: "linux", arch: "x64" })).not.toBeNull();
|
|
});
|
|
|
|
it("revalidates active payloads with an operator-supplied trust store", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const trustStorePath = join(fixture.aiDataDir, "ocr-runtime-trust.json");
|
|
writeFileSync(
|
|
trustStorePath,
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
keys: [
|
|
{
|
|
keyId: "runtime-state-test-key",
|
|
algorithm: "ed25519",
|
|
publicKey: runtimeSigningKey.publicKey.export({ type: "spki", format: "pem" }),
|
|
},
|
|
],
|
|
}),
|
|
"utf-8",
|
|
);
|
|
vi.stubEnv("OCR_RUNTIME_INDEX_KEY_ID", "");
|
|
vi.stubEnv("OCR_RUNTIME_INDEX_PUBLIC_KEY_PEM_B64", "");
|
|
vi.stubEnv("SNAPOTTER_OCR_RUNTIME_TRUST_STORE", trustStorePath);
|
|
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("getOcrRuntimeCapability", () => {
|
|
it("reports only validated active runtime capabilities", () => {
|
|
const fixture = createRuntimeFixture();
|
|
|
|
const capability = getOcrRuntimeCapability({
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
});
|
|
|
|
expect(capability).toMatchObject({
|
|
available: true,
|
|
status: "ready",
|
|
qualities: ["balanced", "best"],
|
|
providers: ["CPUExecutionProvider"],
|
|
descriptor: { family: "ocr", generation: "generation-test" },
|
|
});
|
|
});
|
|
|
|
it("returns empty capabilities when runtime state is unavailable", () => {
|
|
const aiDataDir = mkdtempSync(join(tmpdir(), "snapotter-runtime-state-empty-"));
|
|
temporaryDirectories.push(aiDataDir);
|
|
|
|
expect(getOcrRuntimeCapability({ aiDataDir, platform: "linux", arch: "x64" })).toEqual({
|
|
available: false,
|
|
status: "missing",
|
|
reason: "descriptor-missing",
|
|
qualities: [],
|
|
providers: [],
|
|
});
|
|
});
|
|
|
|
it("fails accurate-runtime admission closed when the effective memory limit drops", () => {
|
|
const fixture = createRuntimeFixture();
|
|
|
|
expect(
|
|
getOcrRuntimeCapability({
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
effectiveMemoryBytes: 4 * 1024 ** 3 - 1,
|
|
}),
|
|
).toMatchObject({
|
|
available: false,
|
|
status: "incompatible",
|
|
reason: "insufficient-memory",
|
|
});
|
|
});
|
|
|
|
it("reports an unreadable identified memory controller as incompatible", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const procFiles = new Map([
|
|
["/proc/self/cgroup", "0::/docker/deadbeef\n"],
|
|
[
|
|
"/proc/self/mountinfo",
|
|
"29 23 0:26 / /sys/fs/cgroup rw,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw\n",
|
|
],
|
|
]);
|
|
|
|
expect(
|
|
getOcrRuntimeCapability({
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
physicalMemoryBytes: 8 * 1024 ** 3,
|
|
readTextFile: (path) => {
|
|
const value = procFiles.get(path);
|
|
if (value === undefined) throw new Error("denied");
|
|
return value;
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
available: false,
|
|
status: "incompatible",
|
|
reason: "memory-capacity-unknown",
|
|
});
|
|
});
|
|
|
|
it("distinguishes incompatible, invalid, and unsupported runtime state", () => {
|
|
const incompatible = createRuntimeFixture();
|
|
expect(
|
|
getOcrRuntimeCapability({
|
|
aiDataDir: incompatible.aiDataDir,
|
|
platform: "linux",
|
|
arch: "arm64",
|
|
}),
|
|
).toMatchObject({
|
|
available: false,
|
|
status: "incompatible",
|
|
reason: "artifact-incompatible",
|
|
});
|
|
|
|
const invalid = createRuntimeFixture();
|
|
mutateDescriptor(invalid.descriptorPath, (descriptor) => {
|
|
descriptor.artifact.arch = null;
|
|
});
|
|
expect(
|
|
getOcrRuntimeCapability({
|
|
aiDataDir: invalid.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toMatchObject({
|
|
available: false,
|
|
status: "invalid",
|
|
reason: "descriptor-invalid",
|
|
});
|
|
|
|
expect(
|
|
getOcrRuntimeCapability({
|
|
aiDataDir: incompatible.aiDataDir,
|
|
platform: "darwin",
|
|
arch: "arm64",
|
|
}),
|
|
).toMatchObject({
|
|
available: false,
|
|
status: "incompatible",
|
|
reason: "unsupported-host",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("OCR activation identity", () => {
|
|
it("reads the exact committed descriptor hash without walking runtime payloads", () => {
|
|
const fixture = createRuntimeFixture();
|
|
const raw = readFileSync(fixture.descriptorPath);
|
|
const expected = {
|
|
generation: "generation-test",
|
|
descriptorSha256: createHash("sha256").update(raw).digest("hex"),
|
|
};
|
|
|
|
expect(
|
|
readCommittedOcrRuntimeActivationIdentity({
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toEqual(expected);
|
|
expect(
|
|
readActiveRuntime("ocr", {
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
})?.activationDescriptorSha256,
|
|
).toBe(expected.descriptorSha256);
|
|
|
|
writeFileSync(fixture.descriptorPath, "not-json\n", "utf8");
|
|
expect(
|
|
readCommittedOcrRuntimeActivationIdentity({
|
|
aiDataDir: fixture.aiDataDir,
|
|
platform: "linux",
|
|
arch: "x64",
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|