mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: make OCR portable and reliable across AMD64 and ARM64 (#519)
* fix: make OCR portable and reliable * fix: harden OCR installation portability * fix: pin OCR partials across downloads * fix: make OCR execution reliably asynchronous * fix: harden OCR portability and docs routes * fix: preserve decoder and docs safeguards
This commit is contained in:
@@ -0,0 +1,904 @@
|
||||
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 { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getOcrRuntimeCapability,
|
||||
readActiveRuntime,
|
||||
readCommittedOcrRuntimeActivationIdentity,
|
||||
readPendingOcrRuntimeForHandoff,
|
||||
resolveAiDataDir,
|
||||
selectOcrRuntimeTarget,
|
||||
} from "../../../packages/ai/src/runtime-state.js";
|
||||
|
||||
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: "2.1.0",
|
||||
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: "2.1.0" },
|
||||
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: "2.1.0",
|
||||
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: "2.1.0",
|
||||
},
|
||||
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: "2.1.0",
|
||||
},
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user