Skip to content

Commit 2757244

Browse files
committed
Add Stage 4 kind discrimination and Stage 5 schema validators
Implement closed-schema validation for all three document kinds (section 02, section 06, section 07, section 08) and the cross-field semantic checks the Stage 5 definition assigns: - closed-schema discipline (unknown field, missing required, no null literal); - reusable field validators: slug, RFC 3339 timestamp form, content path syntax, strict base64url length, NFC for user-visible text, control-character rules, byte-length caps, integer range; - the eleven block kinds with inline content, marks, link targets, form fields, and the submit_form-not-in-transaction rule; - manifest origin.not_after vs canary.issued_at bounds (E_ORIGIN_INVALID with reason), the state_policy submit-budget aggregate (E_SUBMIT_BUDGET, exact wire byte arithmetic), and manifest.updated future-skew (E_SCHEMA_FIELD_SYNTAX); - Stage 4 kind discrimination (spec_version, kind, sig presence and values). The integer grammar runs as a whole-document Stage 5 pre-pass before closed-schema field checks, per section 04's requirement to validate numeric tokens before any conversion; corpus vector 140 fixes this ordering. Tests drive the Stage 4 and Stage 5 corpus vectors and confirm the seven accept vectors pass schema validation cleanly.
1 parent a595055 commit 2757244

9 files changed

Lines changed: 1359 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package org.entangled.pipeline;
2+
3+
import org.entangled.DiagnosticCode;
4+
import org.entangled.RejectException;
5+
import org.entangled.json.JsonValue;
6+
7+
/**
8+
* Stage 4 document-kind discrimination (section 10, section 02):
9+
* <ul>
10+
* <li>{@code spec_version}, {@code kind}, and {@code sig} are present and have
11+
* the right primitive type (string), else {@code E_KIND_MISSING_FIELDS};</li>
12+
* <li>{@code spec_version} is exactly {@code "1.0"}, else
13+
* {@code E_KIND_SPEC_VERSION};</li>
14+
* <li>{@code kind} is one of {@code manifest}/{@code content}/{@code transaction},
15+
* else {@code E_KIND_UNKNOWN}.</li>
16+
* </ul>
17+
*
18+
* <p>This obtains the minimum needed to select a schema for Stage 5; full
19+
* closed-schema validation happens there.
20+
*/
21+
public final class Stage4Kind {
22+
23+
/** The three document kinds. */
24+
public enum Kind { MANIFEST, CONTENT, TRANSACTION }
25+
26+
private Stage4Kind() {
27+
}
28+
29+
public static Kind discriminate(JsonValue root) {
30+
if (!(root instanceof JsonValue.Obj obj)) {
31+
// The top-level document must be a JSON object.
32+
throw new RejectException(DiagnosticCode.E_KIND_MISSING_FIELDS);
33+
}
34+
String specVersion = requireString(obj, "spec_version");
35+
requireString(obj, "kind");
36+
requireString(obj, "sig");
37+
38+
if (!specVersion.equals("1.0")) {
39+
throw new RejectException(DiagnosticCode.E_KIND_SPEC_VERSION);
40+
}
41+
String kind = ((JsonValue.Str) obj.get("kind")).value();
42+
return switch (kind) {
43+
case "manifest" -> Kind.MANIFEST;
44+
case "content" -> Kind.CONTENT;
45+
case "transaction" -> Kind.TRANSACTION;
46+
default -> throw new RejectException(DiagnosticCode.E_KIND_UNKNOWN);
47+
};
48+
}
49+
50+
private static String requireString(JsonValue.Obj obj, String key) {
51+
JsonValue v = obj.get(key);
52+
if (!(v instanceof JsonValue.Str s)) {
53+
// Absent or wrong primitive type.
54+
throw new RejectException(DiagnosticCode.E_KIND_MISSING_FIELDS);
55+
}
56+
return s.value();
57+
}
58+
}
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
package org.entangled.schema;
2+
3+
import java.util.HashSet;
4+
import java.util.List;
5+
import java.util.Set;
6+
import org.entangled.DiagnosticCode;
7+
import org.entangled.RejectException;
8+
import org.entangled.json.JsonValue;
9+
10+
/**
11+
* Block-grammar validation for the eleven block kinds, section 03.
12+
*
13+
* <p>Each block is validated against the closed schema for its declared
14+
* {@code kind}; an unknown kind is {@code E_SCHEMA_ENUM_VIOLATION}. A
15+
* {@code submit_form} block in a transaction document is
16+
* {@code E_SCHEMA_BLOCK_NOT_PERMITTED}. Inline content is validated by
17+
* {@link Inline}; per-block aggregate byte caps are enforced here.
18+
*/
19+
public final class Blocks {
20+
21+
private static final Set<String> KNOWN_KINDS = Set.of(
22+
"paragraph", "heading", "code_block", "quote", "list", "divider",
23+
"image", "link", "submit_form", "feedback", "note");
24+
25+
private static final Set<String> FEEDBACK_VARIANTS = Set.of("success", "info", "warning", "error");
26+
private static final Set<String> NOTE_VARIANTS = Set.of("info", "warning", "danger", "success");
27+
private static final Set<String> MEDIA_TYPES = Set.of("image/png", "image/jpeg", "image/webp");
28+
private static final Set<String> FIELD_KINDS = Set.of("text", "textarea", "select", "checkbox");
29+
30+
private Blocks() {
31+
}
32+
33+
/** Validate one block. {@code transaction} selects document-kind permission rules. */
34+
public static void validate(JsonValue blockValue, boolean transaction) {
35+
JsonValue.Obj b = Fields.obj(blockValue);
36+
String kind = Fields.str(Inline.require(b, "kind"));
37+
if (!KNOWN_KINDS.contains(kind)) {
38+
throw new RejectException(DiagnosticCode.E_SCHEMA_ENUM_VIOLATION);
39+
}
40+
if (transaction && kind.equals("submit_form")) {
41+
throw new RejectException(DiagnosticCode.E_SCHEMA_BLOCK_NOT_PERMITTED);
42+
}
43+
switch (kind) {
44+
case "paragraph" -> paragraph(b);
45+
case "heading" -> heading(b);
46+
case "code_block" -> codeBlock(b);
47+
case "quote" -> quote(b);
48+
case "list" -> list(b);
49+
case "divider" -> divider(b);
50+
case "image" -> image(b);
51+
case "link" -> link(b);
52+
case "submit_form" -> submitForm(b);
53+
case "feedback" -> feedback(b);
54+
case "note" -> note(b);
55+
default -> throw new RejectException(DiagnosticCode.E_SCHEMA_ENUM_VIOLATION);
56+
}
57+
}
58+
59+
private static void paragraph(JsonValue.Obj b) {
60+
Closed.check(b, Set.of("kind", "content"), Set.of("kind", "content"));
61+
int bytes = Inline.validate(b.get("content"), true, true);
62+
cap(bytes, 8 * 1024);
63+
}
64+
65+
private static void heading(JsonValue.Obj b) {
66+
Closed.check(b, Set.of("kind", "level", "content"), Set.of("kind", "level", "content"));
67+
Fields.integerInRange(b.get("level"), 1, 6);
68+
int bytes = Inline.validate(b.get("content"), true, true);
69+
cap(bytes, 200);
70+
}
71+
72+
private static void codeBlock(JsonValue.Obj b) {
73+
Closed.check(b, Set.of("kind", "language", "content"), Set.of("kind", "language", "content"));
74+
slugLanguage(Fields.str(b.get("language")));
75+
String content = Fields.str(b.get("content"));
76+
if (Fields.utf8Len(content) > 32 * 1024) {
77+
throw new RejectException(DiagnosticCode.E_SCHEMA_FIELD_LENGTH);
78+
}
79+
// Control chars other than line feed are forbidden; NFC required (section 04).
80+
Fields.noControlChars(content, true);
81+
Fields.requireNfc(content);
82+
}
83+
84+
private static void quote(JsonValue.Obj b) {
85+
Closed.check(b, Set.of("kind", "content", "attribution"), Set.of("kind", "content"));
86+
int bytes = Inline.validate(b.get("content"), true, true);
87+
cap(bytes, 4 * 1024);
88+
if (b.has("attribution")) {
89+
int abytes = Inline.validate(b.get("attribution"), true, true);
90+
cap(abytes, 200);
91+
}
92+
}
93+
94+
private static void list(JsonValue.Obj b) {
95+
Closed.check(b, Set.of("kind", "ordered", "items"), Set.of("kind", "ordered", "items"));
96+
Fields.bool(b.get("ordered"));
97+
List<JsonValue> items = Fields.arr(b.get("items")).elements();
98+
if (items.isEmpty() || items.size() > 64) {
99+
throw new RejectException(DiagnosticCode.E_SCHEMA_FIELD_LENGTH);
100+
}
101+
int total = 0;
102+
for (JsonValue item : items) {
103+
total += Inline.validate(item, true, true);
104+
}
105+
cap(total, 8 * 1024);
106+
}
107+
108+
private static void divider(JsonValue.Obj b) {
109+
Closed.check(b, Set.of("kind"), Set.of("kind"));
110+
}
111+
112+
private static void image(JsonValue.Obj b) {
113+
Closed.check(b,
114+
Set.of("kind", "src", "sha256", "media_type", "width", "height", "alt", "caption"),
115+
Set.of("kind", "src", "sha256", "media_type", "width", "height", "alt"));
116+
Fields.path(Fields.str(b.get("src")));
117+
sha256Field(Fields.str(b.get("sha256")));
118+
Fields.inEnum(Fields.str(b.get("media_type")), MEDIA_TYPES);
119+
Fields.integerInRange(b.get("width"), 1, 4096);
120+
Fields.integerInRange(b.get("height"), 1, 4096);
121+
String alt = Fields.str(b.get("alt"));
122+
Fields.maxBytes(alt, 1024);
123+
Fields.noControlChars(alt, false);
124+
Fields.requireNfc(alt);
125+
if (b.has("caption")) {
126+
String caption = Fields.str(b.get("caption"));
127+
if (caption.isEmpty()) {
128+
// An empty caption must be omitted, not present as "".
129+
throw Fields.syntax();
130+
}
131+
Fields.maxBytes(caption, 500);
132+
Fields.noControlChars(caption, false);
133+
Fields.requireNfc(caption);
134+
}
135+
}
136+
137+
private static void link(JsonValue.Obj b) {
138+
Closed.check(b, Set.of("kind", "label", "target"), Set.of("kind", "label", "target"));
139+
// link.label is inline content that MUST NOT contain link elements.
140+
int bytes = Inline.validate(b.get("label"), false, true);
141+
cap(bytes, 200);
142+
Inline.validateTarget(b.get("target"));
143+
}
144+
145+
private static void submitForm(JsonValue.Obj b) {
146+
Closed.check(b, Set.of("kind", "label", "submit_to", "fields", "submit_label"),
147+
Set.of("kind", "label", "submit_to", "fields", "submit_label"));
148+
Inline.validate(b.get("label"), false, true);
149+
Fields.path(Fields.str(b.get("submit_to")));
150+
List<JsonValue> fields = Fields.arr(b.get("fields")).elements();
151+
if (fields.isEmpty() || fields.size() > 16) {
152+
throw new RejectException(DiagnosticCode.E_SCHEMA_FIELD_LENGTH);
153+
}
154+
Set<String> names = new HashSet<>();
155+
for (JsonValue f : fields) {
156+
String name = formField(Fields.obj(f));
157+
if (!names.add(name)) {
158+
throw new RejectException(DiagnosticCode.E_SCHEMA_DUPLICATE_ENTRY);
159+
}
160+
}
161+
String submitLabel = Fields.str(b.get("submit_label"));
162+
Fields.maxBytes(submitLabel, 100);
163+
Fields.noControlChars(submitLabel, false);
164+
}
165+
166+
private static String formField(JsonValue.Obj f) {
167+
String kind = Fields.str(Inline.require(f, "kind"));
168+
Fields.inEnum(kind, FIELD_KINDS);
169+
String name;
170+
switch (kind) {
171+
case "text", "textarea" -> {
172+
Closed.check(f, Set.of("kind", "name", "label", "required", "max_length"),
173+
Set.of("kind", "name", "label", "required", "max_length"));
174+
name = sharedFormFields(f);
175+
Fields.integerInRange(f.get("max_length"), 1, 8192);
176+
}
177+
case "select" -> {
178+
Closed.check(f, Set.of("kind", "name", "label", "required", "options"),
179+
Set.of("kind", "name", "label", "required", "options"));
180+
name = sharedFormFields(f);
181+
selectOptions(f.get("options"));
182+
}
183+
case "checkbox" -> {
184+
Closed.check(f, Set.of("kind", "name", "label", "required"),
185+
Set.of("kind", "name", "label", "required"));
186+
name = sharedFormFields(f);
187+
}
188+
default -> throw new RejectException(DiagnosticCode.E_SCHEMA_ENUM_VIOLATION);
189+
}
190+
return name;
191+
}
192+
193+
private static String sharedFormFields(JsonValue.Obj f) {
194+
String name = Fields.str(f.get("name"));
195+
Fields.slug(name, 64);
196+
String label = Fields.str(f.get("label"));
197+
Fields.maxBytes(label, 200);
198+
Fields.noControlChars(label, false);
199+
Fields.bool(f.get("required"));
200+
return name;
201+
}
202+
203+
private static void selectOptions(JsonValue optionsValue) {
204+
List<JsonValue> options = Fields.arr(optionsValue).elements();
205+
if (options.isEmpty() || options.size() > 32) {
206+
throw new RejectException(DiagnosticCode.E_SCHEMA_FIELD_LENGTH);
207+
}
208+
Set<String> values = new HashSet<>();
209+
for (JsonValue o : options) {
210+
JsonValue.Obj opt = Fields.obj(o);
211+
Closed.check(opt, Set.of("value", "label"), Set.of("value", "label"));
212+
String value = Fields.str(opt.get("value"));
213+
Fields.slug(value, 64);
214+
if (!values.add(value)) {
215+
throw new RejectException(DiagnosticCode.E_SCHEMA_DUPLICATE_ENTRY);
216+
}
217+
String label = Fields.str(opt.get("label"));
218+
Fields.maxBytes(label, 200);
219+
Fields.noControlChars(label, false);
220+
}
221+
}
222+
223+
private static void feedback(JsonValue.Obj b) {
224+
Closed.check(b, Set.of("kind", "variant", "content"), Set.of("kind", "variant", "content"));
225+
Fields.inEnum(Fields.str(b.get("variant")), FEEDBACK_VARIANTS);
226+
int bytes = Inline.validate(b.get("content"), true, true);
227+
cap(bytes, 2 * 1024);
228+
}
229+
230+
private static void note(JsonValue.Obj b) {
231+
Closed.check(b, Set.of("kind", "variant", "title", "content"), Set.of("kind", "variant", "content"));
232+
Fields.inEnum(Fields.str(b.get("variant")), NOTE_VARIANTS);
233+
if (b.has("title")) {
234+
String title = Fields.str(b.get("title"));
235+
if (title.isEmpty()) {
236+
throw Fields.syntax();
237+
}
238+
Fields.maxBytes(title, 200);
239+
Fields.noControlChars(title, false);
240+
Fields.requireNfc(title);
241+
}
242+
int bytes = Inline.validate(b.get("content"), true, true);
243+
cap(bytes, 4 * 1024);
244+
}
245+
246+
private static void slugLanguage(String language) {
247+
// code_block.language: [a-z0-9_-], begins [a-z0-9], non-empty, <= 64.
248+
Fields.slug(language, 64);
249+
}
250+
251+
private static void sha256Field(String s) {
252+
// "sha-256:" + 43 base64url chars = 51 chars total.
253+
if (s.length() != 51 || !s.startsWith("sha-256:")) {
254+
throw Fields.syntax();
255+
}
256+
Fields.base64url(s.substring("sha-256:".length()), 32);
257+
}
258+
259+
private static void cap(int bytes, int max) {
260+
if (bytes > max) {
261+
throw new RejectException(DiagnosticCode.E_SCHEMA_FIELD_LENGTH);
262+
}
263+
}
264+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.entangled.schema;
2+
3+
import java.util.Set;
4+
import org.entangled.DiagnosticCode;
5+
import org.entangled.RejectException;
6+
import org.entangled.json.JsonValue;
7+
8+
/**
9+
* Closed-schema discipline for a single object level (section 02).
10+
*
11+
* <p>Given the permitted key set (required plus optional) and the required key
12+
* set for an object, this verifies:
13+
* <ul>
14+
* <li>every present key is permitted, else {@code E_SCHEMA_UNKNOWN_FIELD};</li>
15+
* <li>every required key is present, else {@code E_SCHEMA_REQUIRED_FIELD};</li>
16+
* <li>no permitted present value is the JSON {@code null} literal, else
17+
* {@code E_SCHEMA_NULL_VALUE} (section 04 forbids {@code null} anywhere).</li>
18+
* </ul>
19+
*
20+
* <p>Order: unknown-field detection runs first (a stray key is a closed-schema
21+
* breach regardless of its value), then required-presence, then the null check
22+
* on the permitted values at this level. Nested objects run their own
23+
* {@code Closed} check, so a {@code null} at any depth is caught at its level.
24+
*/
25+
public final class Closed {
26+
27+
private Closed() {
28+
}
29+
30+
public static void check(JsonValue.Obj obj, Set<String> permitted, Set<String> required) {
31+
for (String key : obj.members().keySet()) {
32+
if (!permitted.contains(key)) {
33+
throw new RejectException(DiagnosticCode.E_SCHEMA_UNKNOWN_FIELD);
34+
}
35+
}
36+
for (String key : required) {
37+
if (!obj.members().containsKey(key)) {
38+
throw new RejectException(DiagnosticCode.E_SCHEMA_REQUIRED_FIELD);
39+
}
40+
}
41+
for (JsonValue value : obj.members().values()) {
42+
if (value instanceof JsonValue.Null) {
43+
throw new RejectException(DiagnosticCode.E_SCHEMA_NULL_VALUE);
44+
}
45+
}
46+
}
47+
48+
/** Convenience: a value that must not be the null literal at this position. */
49+
public static void notNull(JsonValue value) {
50+
if (value instanceof JsonValue.Null) {
51+
throw new RejectException(DiagnosticCode.E_SCHEMA_NULL_VALUE);
52+
}
53+
}
54+
}

0 commit comments

Comments
 (0)