JsonValidate.mo
311 lines 7.6 KB
Raw
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor ⚠ breaking 1 day ago
1 /// Validate / normalize JSON text used as raw fragments in proposal GET responses.
2 /// RFC 8259 subset: objects, arrays, strings, numbers, true/false/null.
3 /// Proposal enrich fields must be a JSON array (`suggested_labels_json`) or object (`assistant_suggested_frontmatter_json`).
4
5 import Char "mo:base/Char";
6 import Nat32 "mo:base/Nat32";
7 import Text "mo:base/Text";
8
9 module {
10 /// ASCII `"` for comparisons — cannot use `'"'` as a Char literal (lexer treats `"` as Text).
11 func jsonDquote() : Char {
12 Char.fromNat32(Nat32.fromNat(34));
13 };
14
15 func isAsciiSpace(c : Char) : Bool {
16 c == ' ' or c == '\t' or c == '\n' or c == '\r';
17 };
18
19 public func trimAsciiWhitespace(t : Text) : Text {
20 Text.trim(t, #predicate isAsciiSpace);
21 };
22
23 func skipSpace(chars : [Char], start : Nat) : Nat {
24 var i = start;
25 while (i < chars.size() and isAsciiSpace(chars[i])) {
26 i += 1;
27 };
28 i;
29 };
30
31 func isDigit(c : Char) : Bool {
32 let n = Char.toNat32(c);
33 n >= 48 and n <= 57;
34 };
35
36 func isHex(c : Char) : Bool {
37 let n = Char.toNat32(c);
38 (n >= 48 and n <= 57) or (n >= 65 and n <= 70) or (n >= 97 and n <= 102);
39 };
40
41 func matchLiteral(chars : [Char], start : Nat, lit : Text) : ?Nat {
42 let cs = Text.toArray(lit);
43 var j : Nat = 0;
44 while (j < cs.size()) {
45 if (start + j >= chars.size() or chars[start + j] != cs[j]) {
46 return null;
47 };
48 j += 1;
49 };
50 ?(start + cs.size());
51 };
52
53 func parseString(chars : [Char], start : Nat) : ?Nat {
54 if (start >= chars.size() or chars[start] != jsonDquote()) {
55 return null;
56 };
57 var i = start + 1;
58 while (i < chars.size()) {
59 let c = chars[i];
60 if (c == jsonDquote()) {
61 return ?(i + 1);
62 };
63 if (c == '\\') {
64 if (i + 1 >= chars.size()) {
65 return null;
66 };
67 let esc = chars[i + 1];
68 if (esc == 'u') {
69 if (i + 5 >= chars.size()) {
70 return null;
71 };
72 var k = 0;
73 while (k < 4) {
74 if (not isHex(chars[i + 2 + k])) {
75 return null;
76 };
77 k += 1;
78 };
79 i += 6;
80 } else if (
81 esc == jsonDquote() or esc == '\\' or esc == '/' or esc == 'b' or esc == 'f' or esc == 'n' or esc == 'r' or esc == 't'
82 ) {
83 i += 2;
84 } else {
85 return null;
86 };
87 } else {
88 let code = Char.toNat32(c);
89 if (code < 32) {
90 return null;
91 };
92 i += 1;
93 };
94 };
95 null;
96 };
97
98 func parseNumber(chars : [Char], start : Nat) : ?Nat {
99 var i = start;
100 if (i < chars.size() and chars[i] == '-') {
101 i += 1;
102 };
103 if (i >= chars.size()) {
104 return null;
105 };
106 if (not isDigit(chars[i])) {
107 return null;
108 };
109 if (chars[i] == '0') {
110 i += 1;
111 } else {
112 while (i < chars.size() and isDigit(chars[i])) {
113 i += 1;
114 };
115 };
116 if (i < chars.size() and chars[i] == '.') {
117 i += 1;
118 if (i >= chars.size() or not isDigit(chars[i])) {
119 return null;
120 };
121 while (i < chars.size() and isDigit(chars[i])) {
122 i += 1;
123 };
124 };
125 if (i < chars.size() and (chars[i] == 'e' or chars[i] == 'E')) {
126 i += 1;
127 if (i < chars.size() and (chars[i] == '+' or chars[i] == '-')) {
128 i += 1;
129 };
130 if (i >= chars.size() or not isDigit(chars[i])) {
131 return null;
132 };
133 while (i < chars.size() and isDigit(chars[i])) {
134 i += 1;
135 };
136 };
137 ?i;
138 };
139
140 func parseArray(chars : [Char], start : Nat) : ?Nat {
141 if (start >= chars.size() or chars[start] != '[') {
142 return null;
143 };
144 var i = start + 1;
145 i := skipSpace(chars, i);
146 if (i < chars.size() and chars[i] == ']') {
147 return ?(i + 1);
148 };
149 while (true) {
150 switch (parseValue(chars, i)) {
151 case null { return null };
152 case (?j) { i := j };
153 };
154 i := skipSpace(chars, i);
155 if (i >= chars.size()) {
156 return null;
157 };
158 if (chars[i] == ']') {
159 return ?(i + 1);
160 };
161 if (chars[i] == ',') {
162 i += 1;
163 } else {
164 return null;
165 };
166 };
167 null;
168 };
169
170 func parseObject(chars : [Char], start : Nat) : ?Nat {
171 if (start >= chars.size() or chars[start] != '{') {
172 return null;
173 };
174 var i = start + 1;
175 i := skipSpace(chars, i);
176 if (i < chars.size() and chars[i] == '}') {
177 return ?(i + 1);
178 };
179 while (true) {
180 switch (parseString(chars, i)) {
181 case null { return null };
182 case (?j) { i := j };
183 };
184 i := skipSpace(chars, i);
185 if (i >= chars.size() or chars[i] != ':') {
186 return null;
187 };
188 i += 1;
189 switch (parseValue(chars, i)) {
190 case null { return null };
191 case (?j) { i := j };
192 };
193 i := skipSpace(chars, i);
194 if (i >= chars.size()) {
195 return null;
196 };
197 if (chars[i] == '}') {
198 return ?(i + 1);
199 };
200 if (chars[i] == ',') {
201 i += 1;
202 } else {
203 return null;
204 };
205 };
206 null;
207 };
208
209 func parseValue(chars : [Char], start : Nat) : ?Nat {
210 let i = skipSpace(chars, start);
211 if (i >= chars.size()) {
212 return null;
213 };
214 let c = chars[i];
215 if (c == jsonDquote()) {
216 parseString(chars, i);
217 } else if (c == '[') {
218 parseArray(chars, i);
219 } else if (c == '{') {
220 parseObject(chars, i);
221 } else if (c == 't') {
222 matchLiteral(chars, i, "true");
223 } else if (c == 'f') {
224 matchLiteral(chars, i, "false");
225 } else if (c == 'n') {
226 matchLiteral(chars, i, "null");
227 } else if (c == '-' or isDigit(c)) {
228 parseNumber(chars, i);
229 } else {
230 null;
231 };
232 };
233
234 func consumesFullValueStartingWith(chars : [Char], expectOpen : Char) : Bool {
235 if (chars.size() == 0) {
236 return false;
237 };
238 let i0 = skipSpace(chars, 0);
239 if (i0 >= chars.size() or chars[i0] != expectOpen) {
240 return false;
241 };
242 switch (parseValue(chars, 0)) {
243 case null { false };
244 case (?j) { skipSpace(chars, j) == chars.size() };
245 };
246 };
247
248 /// Safe fragment for `"suggested_labels":` … in GET JSON (must be a JSON array).
249 public func normalizeJsonArrayFragment(raw : Text) : Text {
250 let t = trimAsciiWhitespace(raw);
251 if (t.size() == 0) {
252 return "[]";
253 };
254 let chars = Text.toArray(t);
255 if (consumesFullValueStartingWith(chars, '[')) {
256 t;
257 } else {
258 "[]";
259 };
260 };
261
262 /// Safe fragment for `"assistant_suggested_frontmatter":` … in GET JSON (must be a JSON object).
263 public func normalizeJsonObjectFragment(raw : Text) : Text {
264 let t = trimAsciiWhitespace(raw);
265 if (t.size() == 0) {
266 return "{}";
267 };
268 let chars = Text.toArray(t);
269 if (consumesFullValueStartingWith(chars, '{')) {
270 t;
271 } else {
272 "{}";
273 };
274 };
275
276 public type EnrichPrepare = {
277 #ok : Text;
278 #coercedDefault;
279 #tooLarge;
280 };
281
282 public func prepareEnrichJsonArray(raw : Text, maxChars : Nat) : EnrichPrepare {
283 let t = trimAsciiWhitespace(raw);
284 if (t.size() == 0) {
285 return #coercedDefault;
286 };
287 let chars = Text.toArray(t);
288 if (not consumesFullValueStartingWith(chars, '[')) {
289 return #coercedDefault;
290 };
291 if (t.size() > maxChars) {
292 return #tooLarge;
293 };
294 #ok(t);
295 };
296
297 public func prepareEnrichJsonObject(raw : Text, maxChars : Nat) : EnrichPrepare {
298 let t = trimAsciiWhitespace(raw);
299 if (t.size() == 0) {
300 return #coercedDefault;
301 };
302 let chars = Text.toArray(t);
303 if (not consumesFullValueStartingWith(chars, '{')) {
304 return #coercedDefault;
305 };
306 if (t.size() > maxChars) {
307 return #tooLarge;
308 };
309 #ok(t);
310 };
311 };
File History 2 commits
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd feat(calendar): enforce agent context tiers in retrieval AP… Human minor 1 day ago
sha256:9103f98c89257ed2b01c237cea895dabb3e85ea337dccb1161c175e4422355b6 docs: accept Calendar Events v0 spec with Phase 0 security … Human 1 day ago