mirror of
https://github.com/morluto/rea.git
synced 2026-10-27 01:45:32 +00:00
fix(environment): refuse a loaded state that data/get would write past the intake limit
data/add measured only the document it was given, while data/get writes the state back with the members a document may omit (types, the state Evidence id, view fields and record citations). A document just under 32 MiB could therefore load and be reported larger than any intake accepts, breaking the data/get then data/add round trip. replace now measures the state as data/get writes it, part by part, and refuses it past the limit, leaving the state unchanged. The limit moves to the state document module, so the application store can read it; the intake re-exports it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25b6622d3a
commit
6b8db19891
6 files changed
+91
-9
No files matched your search
@@ -424,8 +424,10 @@ within 30 seconds, including reading the body; `mimeType`, when given, is
|
||||
`application/json`. A `file://` URL naming another host, or a UNC path such as
|
||||
`file:////server/share/state.json`, is refused before anything is opened, so a
|
||||
load never reaches a network share. A loaded document is at most 32
|
||||
MiB; an inline part also stays within the 4 MiB request body bound, so larger
|
||||
states arrive by `uri`. Anything else, including `{"parts": []}`, is `-32602
|
||||
MiB, and so is the state as `data/get` writes it back with the members a
|
||||
document may omit (`types`, `state_evidence_id`, record citations), so a
|
||||
reported state always loads again; an inline part also stays within the 4 MiB
|
||||
request body bound, so larger states arrive by `uri`. Anything else, including `{"parts": []}`, is `-32602
|
||||
invalid params`; a download that fails or answers a non-2xx status is
|
||||
`-32000`. Errors never echo the uri or the content; a file or download failure
|
||||
names its category in `error.data.reason`: `not-found`, `permission`,
|
||||
|
||||
@@ -77,7 +77,9 @@ this increment: write tools are not served from the state.
|
||||
`mimeType` when given is `application/json`) and `data/get` (`{parts:
|
||||
[{kind: "data", data: rea_state}]}`). A `file://` uri whose host is neither
|
||||
empty nor `localhost`, or whose path is a UNC path, is `-32602` before
|
||||
anything is opened. Anything else is `-32602`, including empty `parts`; a
|
||||
anything is opened. A document that `data/get` would write back past 32 MiB,
|
||||
once it adds the members a document may omit, is `-32602`, so a reported
|
||||
state always loads again. Anything else is `-32602`, including empty `parts`; a
|
||||
failed download is `-32000`; errors never echo a uri or content and name a
|
||||
coarse failure category in `error.data.reason`. Calls run one at a time in
|
||||
arrival order across requests; every message of a batch is queued when the
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
loadEnvironmentState,
|
||||
prepareServedState,
|
||||
} from "./environmentState.js";
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js";
|
||||
import { EnvironmentStateStore } from "./environmentStateStore.js";
|
||||
|
||||
const fixture = environmentStateServeFixture();
|
||||
@@ -373,6 +374,48 @@ describe("EnvironmentStateStore", () => {
|
||||
expect(refused.ok).toBe(false);
|
||||
expect(store.snapshot()).toEqual(before);
|
||||
});
|
||||
|
||||
it("refuses a document that data/get would write back past what data/add loads", () => {
|
||||
const store = new EnvironmentStateStore(state);
|
||||
const { rea_state: held } = store.snapshot();
|
||||
// Without the members data/get adds back, the document fits only until they return.
|
||||
const document = {
|
||||
rea_state: {
|
||||
version: held.version,
|
||||
entities: held.entities.map(({ type, collection, id, fields }) => ({
|
||||
type,
|
||||
collection,
|
||||
id,
|
||||
fields: { ...fields },
|
||||
})),
|
||||
documents: held.documents,
|
||||
},
|
||||
};
|
||||
const entity = document.rea_state.entities[0]!;
|
||||
const { fields } = entity;
|
||||
const idField = state.types.find(
|
||||
(type) => type.collection_template === entity.type,
|
||||
)?.id_field;
|
||||
const text = Object.keys(fields).find(
|
||||
(field) => field !== idField && typeof fields[field] === "string",
|
||||
)!;
|
||||
fields[text] = `${String(fields[text])}${"a".repeat(
|
||||
STATE_INTAKE_LIMIT_BYTES -
|
||||
Buffer.byteLength(JSON.stringify(document), "utf8"),
|
||||
)}`;
|
||||
expect(Buffer.byteLength(JSON.stringify(document), "utf8")).toBe(
|
||||
STATE_INTAKE_LIMIT_BYTES,
|
||||
);
|
||||
const before = store.snapshot();
|
||||
const replaced = store.replace(document);
|
||||
expect(!replaced.ok && replaced.error.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
reason: "out_of_range",
|
||||
maximum: STATE_INTAKE_LIMIT_BYTES,
|
||||
}),
|
||||
]);
|
||||
expect(store.snapshot()).toEqual(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rea-state/v1 validation", () => {
|
||||
|
||||
@@ -23,6 +23,9 @@ import type { EnvironmentState } from "./environmentState.js";
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import { REA_STATE_FORMAT } from "./environmentTools.js";
|
||||
|
||||
/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */
|
||||
export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** A document as the state holds it; only a value equal to the Evidence's cites the record it came from. */
|
||||
export type HeldDocument = Omit<StateDocument, "record_ordinal"> & {
|
||||
readonly record_ordinal?: number;
|
||||
|
||||
@@ -2,16 +2,26 @@ import type {
|
||||
StateEntity,
|
||||
StateType,
|
||||
} from "../../domain/analysisView/environmentStateRules.js";
|
||||
import type { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { ok, type Result } from "../../domain/result.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { jsonParts } from "../../domain/jsonSerialization.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||
import { ENVIRONMENT_SERVE_OPERATION } from "./environmentSource.js";
|
||||
import type { EnvironmentState } from "./environmentState.js";
|
||||
import {
|
||||
parseStateDocument,
|
||||
STATE_INTAKE_LIMIT_BYTES,
|
||||
type HeldDocument,
|
||||
type StateContent,
|
||||
} from "./environmentStateDocument.js";
|
||||
|
||||
/** The UTF-8 bytes a value's JSON takes, counted part by part so it is never one string. */
|
||||
const jsonBytes = (value: unknown): number => {
|
||||
let bytes = 0;
|
||||
for (const part of jsonParts(value)) bytes += Buffer.byteLength(part, "utf8");
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** What `data/get` reports: the current state as a `rea-state/v1` document. */
|
||||
export interface StateSnapshot {
|
||||
readonly rea_state: {
|
||||
@@ -52,6 +62,24 @@ export class EnvironmentStateStore {
|
||||
replace(document: unknown): Result<void, AnalysisInputError> {
|
||||
const content = parseStateDocument(this.state, document);
|
||||
if (!content.ok) return content;
|
||||
// data/get writes members a document may omit, so the state it reports must still be one data/add loads.
|
||||
if (jsonBytes(this.#snapshotOf(content.value)) > STATE_INTAKE_LIMIT_BYTES)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
ENVIRONMENT_SERVE_OPERATION,
|
||||
{
|
||||
userMessage: `The state would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it.`,
|
||||
},
|
||||
[
|
||||
{
|
||||
path: [],
|
||||
reason: "out_of_range",
|
||||
message: `would exceed ${String(STATE_INTAKE_LIMIT_BYTES)} bytes as data/get writes it`,
|
||||
maximum: STATE_INTAKE_LIMIT_BYTES,
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
this.#install(content.value);
|
||||
return ok(undefined);
|
||||
}
|
||||
@@ -83,13 +111,17 @@ export class EnvironmentStateStore {
|
||||
}
|
||||
|
||||
snapshot(): StateSnapshot {
|
||||
return this.#snapshotOf(this.#content);
|
||||
}
|
||||
|
||||
#snapshotOf(content: StateContent): StateSnapshot {
|
||||
return {
|
||||
rea_state: {
|
||||
version: 1,
|
||||
state_evidence_id: this.state.evidenceId,
|
||||
types: this.state.types,
|
||||
entities: this.#content.entities,
|
||||
documents: this.#content.documents,
|
||||
entities: content.entities,
|
||||
documents: content.documents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { STATE_INTAKE_LIMIT_BYTES } from "../../application/environment/environmentStateDocument.js";
|
||||
import { mediaTypeEssence } from "../../domain/analysisView/webNetworkRouteTemplates.js";
|
||||
import { isExactJsonNumber } from "../../domain/exactJsonNumber.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
@@ -44,8 +45,7 @@ export const CANCELLED: RpcError = {
|
||||
data: { reason: "cancelled" },
|
||||
};
|
||||
|
||||
/** A loaded state document is at most this large, whether it arrives as bytes, a file or a download. */
|
||||
export const STATE_INTAKE_LIMIT_BYTES = 32 * 1024 * 1024;
|
||||
export { STATE_INTAKE_LIMIT_BYTES };
|
||||
/** An https state download must finish within this time. */
|
||||
export const STATE_FETCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
|
||||
Reference in new issue
Block a user