001package gudusoft.gsqlparser.slpc.json;
002
003import gudusoft.gsqlparser.slpc.model.SlpcDocument;
004import gudusoft.gsqlparser.slpc.model.SlpcObject;
005
006import java.io.ByteArrayOutputStream;
007import java.math.BigInteger;
008import java.nio.ByteBuffer;
009import java.nio.charset.CharacterCodingException;
010import java.nio.charset.CodingErrorAction;
011import java.nio.charset.StandardCharsets;
012import java.util.ArrayList;
013import java.util.Arrays;
014import java.util.Collections;
015import java.util.Comparator;
016import java.util.List;
017
018/** Strict reader and deterministic UTF-8 writer for SLPC JSON. */
019public final class SlpcJson {
020
021    private SlpcJson() {
022    }
023
024    public static SlpcDocument readDocument(byte[] utf8) {
025        Object value = new Parser(decodeUtf8(utf8)).parse();
026        if (!(value instanceof SlpcObject)) {
027            throw new SlpcJsonException("SLPC document root must be an object");
028        }
029        return new SlpcDocument((SlpcObject) value);
030    }
031
032    public static SlpcObject readObject(byte[] utf8) {
033        Object value = new Parser(decodeUtf8(utf8)).parse();
034        if (!(value instanceof SlpcObject)) {
035            throw new SlpcJsonException("JSON root must be an object");
036        }
037        return (SlpcObject) value;
038    }
039
040    public static byte[] canonicalBytes(SlpcDocument document) {
041        return canonicalBytes(document.root());
042    }
043
044    public static byte[] canonicalBytes(Object value) {
045        ByteArrayOutputStream output = new ByteArrayOutputStream();
046        write(value, output);
047        return output.toByteArray();
048    }
049
050    public static String canonicalString(Object value) {
051        return new String(canonicalBytes(value), StandardCharsets.UTF_8);
052    }
053
054    private static String decodeUtf8(byte[] value) {
055        try {
056            return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
057                    .onUnmappableCharacter(CodingErrorAction.REPORT).decode(ByteBuffer.wrap(value)).toString();
058        } catch (CharacterCodingException invalid) {
059            throw new SlpcJsonException("Input is not well-formed UTF-8");
060        }
061    }
062
063    private static void write(Object value, ByteArrayOutputStream output) {
064        write(value, output, false);
065    }
066
067    private static void write(Object value, ByteArrayOutputStream output, boolean extensionPayload) {
068        if (value == null) {
069            ascii(output, "null");
070        } else if (value instanceof String) {
071            writeString((String) value, output);
072        } else if (value instanceof Boolean || value instanceof BigInteger) {
073            ascii(output, value.toString());
074        } else if (value instanceof SlpcObject) {
075            writeObject((SlpcObject) value, output, extensionPayload);
076        } else if (value instanceof List<?>) {
077            output.write('[');
078            boolean first = true;
079            for (Object item : (List<?>) value) {
080                if (!first) {
081                    output.write(',');
082                }
083                write(item, output, extensionPayload);
084                first = false;
085            }
086            output.write(']');
087        } else if (value instanceof Byte || value instanceof Short || value instanceof Integer || value instanceof Long) {
088            ascii(output, String.valueOf(value));
089        } else {
090            throw new SlpcJsonException("Unsupported JSON value type: " + value.getClass().getName());
091        }
092    }
093
094    private static void writeObject(final SlpcObject object, ByteArrayOutputStream output, boolean extensionPayload) {
095        List<String> names = new ArrayList<String>(object.names());
096        final List<String> schemaOrder = extensionPayload ? null : schemaFieldOrder(object);
097        if (schemaOrder == null) {
098            Collections.sort(names, UTF8_COMPARATOR);
099        } else {
100            Collections.sort(names, new Comparator<String>() { @Override public int compare(String left, String right) {
101                int leftIndex = schemaOrder.indexOf(left), rightIndex = schemaOrder.indexOf(right);
102                if (leftIndex < 0 && rightIndex < 0) return UTF8_COMPARATOR.compare(left, right);
103                if (leftIndex < 0) return 1;
104                if (rightIndex < 0) return -1;
105                return leftIndex - rightIndex;
106            }});
107        }
108        output.write('{');
109        boolean first = true;
110        for (String name : names) {
111            if (!first) {
112                output.write(',');
113            }
114            writeString(name, output);
115            output.write(':');
116            boolean payload = extensionPayload || ("payload".equals(name) && object.contains("extensionType"));
117            write(object.get(name), output, payload);
118            first = false;
119        }
120        output.write('}');
121    }
122
123    /** Package-visible so the conformance test can detect schema/order drift. */
124    /**
125     * The canonical property order for the given SLPC object shape.
126     *
127     * <p>PUBLIC ON PURPOSE: every shipped jar is ProGuard-obfuscated under a
128     * compatibility-first policy that preserves public and protected members
129     * and renames everything else. A package-private method is therefore
130     * unreachable from the released jar, which is what the test suite runs
131     * against in the nightly obfuscated-jar gate. Keeping this public is what
132     * lets that gate execute the real shipped bytes.</p>
133     *
134     * @since 4.2.6
135     */
136    public static List<String> schemaFieldOrder(SlpcObject value) {
137        if (value.contains("contractVersion")) return fields("contractVersion", "identitySpecVersion", "producer", "artifacts", "statements", "endpoints", "facts", "factCandidates", "datasetOperationInfluences", "datasetOperationCandidates", "occurrences", "diagnostics", "extensions", "objectRelationships", "objectRelationshipCandidates", "statementOperations", "joinSemantics", "intermediateGraphs");
138        if (value.contains("reportVersion")) return fields("reportVersion", "projectorName", "projectorVersion", "targetType", "inputCounts", "outcomeCounts", "records", "diagnosticCounts");
139        if (value.contains("engineVersion") && value.contains("capabilities")) return fields("engineKind", "engineVersion", "adapterName", "adapterVersion", "capabilities");
140        if (value.contains("code") && value.contains("status")) return fields("code", "status");
141        if (value.contains("artifactId") && value.contains("sourceKind")) return fields("artifactId", "sourceKind", "logicalLocator", "sqlDialect", "contentHash", "mediaType");
142        if (value.contains("statementIndex")) return fields("statementId", "artifactId", "statementIndex", "kind", "queryFingerprint", "sourceLocation");
143        if (value.contains("endpointLevel")) return fields("endpointRef", "canonicalEndpointId", "endpointLevel", "objectKind", "displaySegments", "identity", "resolutionStatus", "publicationState", "scope", "extensions");
144        if (value.contains("display") && value.contains("inputForm")) return fields("kind", "display", "inputForm", "provenance");
145        if (value.contains("strength")) {
146            String kind = value.string("kind");
147            if ("COMPOSITE_EXACT".equals(kind) || "COMPOSITE_APPROXIMATE".equals(kind)) return fields("kind", "strength", "qualificationPolicyId", "segments");
148            if ("AUTHORITATIVE_EXTERNAL".equals(kind)) return fields("kind", "strength", "system", "identityVersion", "opaqueId");
149            if ("URI_EXACT".equals(kind)) return fields("kind", "strength", "normalizationPolicyId", "scheme", "locatorDigest");
150            return fields("kind", "strength", "reason");
151        }
152        if (value.contains("segmentKind")) {
153            if (value.contains("payload")) return fields("segmentKind", "identityKind", "policyId", "objectGroup", "payload");
154            return fields("segmentKind", "identityKind", "system", "identityVersion", "opaqueId");
155        }
156        if (value.contains("scopeKind")) return fields("scopeKind", "scopeId", "definedAtStatementId");
157        if (value.contains("factId")) return fields("factId", "sourceEndpointRef", "targetEndpointRef", "axis", "role", "operationKind", "operationInputKind", "qualitySummary", "extensions");
158        if (value.contains("candidateFingerprint") && value.contains("axis")) return fields("candidateId", "candidateFingerprint", "sourceEndpointRef", "targetEndpointRef", "axis", "role", "operationKind", "operationInputKind", "candidateReasons", "qualitySummary", "extensions");
159        if (value.contains("influenceId")) return fields("influenceId", "sourceEndpointRef", "targetEndpointRef", "operationKind", "qualitySummary", "extensions");
160        if (value.contains("candidateFingerprint") && value.contains("sourceEndpointRef")) return fields("candidateId", "candidateFingerprint", "sourceEndpointRef", "targetEndpointRef", "operationKind", "candidateReasons", "qualitySummary", "extensions");
161        if (value.contains("relationshipId")) return fields("relationshipId", "relationshipKind", "participants", "keyFields", "keyPairs", "qualitySummary", "extensions");
162        if (value.contains("candidateFingerprint") && value.contains("relationshipKind")) return fields("candidateId", "candidateFingerprint", "relationshipKind", "participants", "keyFields", "keyPairs", "candidateReasons", "qualitySummary", "extensions");
163        if (value.contains("foreignKeyFieldRef")) return fields("ordinal", "foreignKeyFieldRef", "referencedFieldRef");
164        if (value.contains("fieldEndpointRef")) return fields("ordinal", "fieldEndpointRef");
165        if (value.contains("statementOperationId")) return fields("statementOperationId", "statementId", "operationOrdinal", "operationKind", "participants", "contentSemantics", "quality", "sourceLocations", "extensions");
166        if (value.contains("joinSemanticsId")) return fields("joinSemanticsId", "statementId", "joinOrdinal", "joinType", "conditionForm", "leftRelationEndpointRef", "rightRelationEndpointRef", "leftSideSemantics", "rightSideSemantics", "keyPairs", "predicate", "refinesFactIds", "refinesFactCandidateFingerprints", "quality", "sourceLocations", "extensions");
167        if (value.contains("leftFieldEndpointRef")) return fields("ordinal", "leftFieldEndpointRef", "rightFieldEndpointRef", "operator");
168        if (value.contains("graphFingerprint")) return fields("graphRef", "graphFingerprint", "statementId", "graphOrdinal", "graphKind", "analysisCompleteness", "nodes", "edges", "paths", "extensions");
169        if (value.contains("nodeFingerprint")) return fields("nodeRef", "nodeFingerprint", "nodeOrdinal", "nodeKind", "localName", "parentNodeRef", "boundaryEndpointRef", "nodeEvidence", "extensions");
170        if (value.contains("edgeFingerprint")) return fields("edgeRef", "edgeFingerprint", "source", "target", "axis", "role", "operationKind", "operationInputKind", "transformation", "predicate", "extensions");
171        if (value.contains("pathFingerprint")) return fields("pathRef", "pathFingerprint", "edgeRefs", "extensions");
172        if (value.contains("stepFingerprint")) return fields("stepRef", "stepFingerprint", "stepOrdinal", "operationFamily", "operationCode", "inputBindings", "resultAnchor", "expressionEvidence", "extensions");
173        if (value.contains("roleCode")) return fields("ordinal", "roleCode", "edgeRef");
174        if (value.contains("occurrenceId")) return fields("occurrenceId", "ownerRef", "artifactId", "statementId", "evidenceKind", "origin", "procedureEndpointRef", "procedureCanonicalEndpointId", "transformation", "predicate", "quality", "sourceLocations", "rowImpactDetails", "literalInputs", "copyClassifications", "supportPathRefs", "extensions");
175        if (value.contains("engineKind") && value.contains("adapterName")) return fields("engineKind", "adapterName");
176        if (value.contains("fingerprint") && value.contains("locationNullReason")) return fields("text", "fingerprint", "sourceLocation", "locationNullReason");
177        if (value.contains("clauseKind")) return fields("clauseKind", "semanticScope", "projectionFieldSetKind", "projectedFieldEndpointRefs", "projectionCompleteness");
178        if (value.contains("literalKind")) return fields("inputOrdinal", "literalKind", "text", "redactionOrdinal", "fingerprint", "sourceLocation", "locationNullReason");
179        if (value.contains("classification")) return fields("classification", "provenance", "projectorProfileCode");
180        if (value.contains("graphRef") && value.contains("pathRef")) return fields("graphRef", "pathRef");
181        if (value.contains("resolutionGrade")) {
182            if (value.contains("projectionEligibility")) return fields("projectionEligibility", "resolutionGrade", "evaluationModel", "semanticProof", "dynamic", "confidenceMicros", "evidenceCount");
183            return fields("resolutionGrade", "evaluationModel", "semanticProof", "dynamic", "confidenceMicros");
184        }
185        if (value.contains("severity") && value.contains("scope")) return fields("code", "severity", "scope", "ref", "message", "sourceLocation", "extensions");
186        if (value.contains("extensionType")) return fields("extensionType", "schemaVersion", "criticality", "payload");
187        if (value.contains("STANDARD") && value.contains("UNSUPPORTED")) return fields("STANDARD", "EXTENSION_ONLY", "CONDITIONAL", "INELIGIBLE", "UNSUPPORTED");
188        if (value.contains("facts") && value.contains("total")) return fields("facts", "factCandidates", "datasetOperationInfluences", "datasetOperationCandidates", "objectRelationships", "objectRelationshipCandidates", "statementOperations", "joinSemantics", "intermediateGraphs", "total");
189        if (value.contains("outcome") && value.contains("standardOutputCount")) return fields("ownerRef", "outcome", "standardOutputCount", "extensionTypes", "diagnosticCodes");
190        if (value.contains("count") && value.contains("code")) return fields("code", "count");
191        if (value.contains("startLine")) return fields("artifactId", "statementId", "startLine", "startColumn", "endLine", "endColumn");
192        if (value.contains("kind") && value.contains("portCode")) {
193            return "ENDPOINT".equals(value.string("kind")) ? fields("kind", "endpointRef", "portCode") : fields("kind", "nodeRef", "portCode");
194        }
195        if (value.contains("kind") && value.contains("id")) return fields("kind", "id");
196        if (value.contains("role") && value.contains("endpointRef")) return fields("role", "endpointRef");
197        if (value.contains("algorithm") && value.contains("value")) return fields("algorithm", "value");
198        if (value.contains("steps")) return fields("steps");
199        return null;
200    }
201
202    private static List<String> fields(String... names) {
203        return Arrays.asList(names);
204    }
205
206    private static void writeString(String value, ByteArrayOutputStream output) {
207        rejectUnpairedSurrogates(value);
208        output.write('"');
209        for (int index = 0; index < value.length(); index++) {
210            char character = value.charAt(index);
211            switch (character) {
212                case '"': ascii(output, "\\\""); break;
213                case '\\': ascii(output, "\\\\"); break;
214                case '\b': ascii(output, "\\b"); break;
215                case '\f': ascii(output, "\\f"); break;
216                case '\n': ascii(output, "\\n"); break;
217                case '\r': ascii(output, "\\r"); break;
218                case '\t': ascii(output, "\\t"); break;
219                default:
220                    if (character < 0x20) {
221                        ascii(output, String.format("\\u%04x", (int) character));
222                    } else {
223                        int codePoint = Character.codePointAt(value, index);
224                        byte[] bytes = new String(Character.toChars(codePoint)).getBytes(StandardCharsets.UTF_8);
225                        output.write(bytes, 0, bytes.length);
226                        if (Character.isSupplementaryCodePoint(codePoint)) {
227                            index++;
228                        }
229                    }
230            }
231        }
232        output.write('"');
233    }
234
235    private static void ascii(ByteArrayOutputStream output, String value) {
236        byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
237        output.write(bytes, 0, bytes.length);
238    }
239
240    private static void rejectUnpairedSurrogates(String value) {
241        for (int index = 0; index < value.length(); index++) {
242            char character = value.charAt(index);
243            if (Character.isHighSurrogate(character)) {
244                if (index + 1 >= value.length() || !Character.isLowSurrogate(value.charAt(index + 1))) {
245                    throw new SlpcJsonException("Unpaired high surrogate in JSON string");
246                }
247                index++;
248            } else if (Character.isLowSurrogate(character)) {
249                throw new SlpcJsonException("Unpaired low surrogate in JSON string");
250            }
251        }
252    }
253
254    private static final Comparator<String> UTF8_COMPARATOR = new Comparator<String>() {
255        @Override
256        public int compare(String left, String right) {
257            byte[] a = left.getBytes(StandardCharsets.UTF_8);
258            byte[] b = right.getBytes(StandardCharsets.UTF_8);
259            int common = Math.min(a.length, b.length);
260            for (int index = 0; index < common; index++) {
261                int difference = (a[index] & 0xff) - (b[index] & 0xff);
262                if (difference != 0) {
263                    return difference;
264                }
265            }
266            return a.length - b.length;
267        }
268    };
269
270    private static final class Parser {
271        private final String input;
272        private int offset;
273
274        private Parser(String input) {
275            this.input = input;
276        }
277
278        private Object parse() {
279            skipWhitespace();
280            Object value = value();
281            skipWhitespace();
282            if (offset != input.length()) {
283                fail("Unexpected trailing content");
284            }
285            return value;
286        }
287
288        private Object value() {
289            if (offset >= input.length()) {
290                fail("Unexpected end of JSON");
291            }
292            char current = input.charAt(offset);
293            if (current == '{') return object();
294            if (current == '[') return array();
295            if (current == '"') return string();
296            if (current == 't') return literal("true", Boolean.TRUE);
297            if (current == 'f') return literal("false", Boolean.FALSE);
298            if (current == 'n') return literal("null", null);
299            if (current == '-' || (current >= '0' && current <= '9')) return integer();
300            fail("Unexpected character '" + current + "'");
301            return null;
302        }
303
304        private SlpcObject object() {
305            offset++;
306            skipWhitespace();
307            SlpcObject.Builder builder = SlpcObject.builder();
308            java.util.HashSet<String> names = new java.util.HashSet<String>();
309            if (take('}')) return builder.build();
310            while (true) {
311                if (peek() != '"') fail("Object member name must be a string");
312                String name = string();
313                if (!names.add(name)) fail("Duplicate object member '" + name + "'");
314                skipWhitespace();
315                require(':');
316                skipWhitespace();
317                builder.put(name, value());
318                skipWhitespace();
319                if (take('}')) return builder.build();
320                require(',');
321                skipWhitespace();
322            }
323        }
324
325        private List<Object> array() {
326            offset++;
327            skipWhitespace();
328            ArrayList<Object> result = new ArrayList<Object>();
329            if (take(']')) return Collections.unmodifiableList(result);
330            while (true) {
331                result.add(value());
332                skipWhitespace();
333                if (take(']')) return Collections.unmodifiableList(result);
334                require(',');
335                skipWhitespace();
336            }
337        }
338
339        private String string() {
340            require('"');
341            StringBuilder result = new StringBuilder();
342            while (offset < input.length()) {
343                char current = input.charAt(offset++);
344                if (current == '"') {
345                    String value = result.toString();
346                    rejectUnpairedSurrogates(value);
347                    return value;
348                }
349                if (current == '\\') {
350                    if (offset >= input.length()) fail("Incomplete escape sequence");
351                    char escaped = input.charAt(offset++);
352                    switch (escaped) {
353                        case '"': result.append('"'); break;
354                        case '\\': result.append('\\'); break;
355                        case '/': result.append('/'); break;
356                        case 'b': result.append('\b'); break;
357                        case 'f': result.append('\f'); break;
358                        case 'n': result.append('\n'); break;
359                        case 'r': result.append('\r'); break;
360                        case 't': result.append('\t'); break;
361                        case 'u': result.append(unicodeEscape()); break;
362                        default: fail("Invalid escape sequence \\" + escaped);
363                    }
364                } else {
365                    if (current < 0x20) fail("Unescaped control character in string");
366                    result.append(current);
367                }
368            }
369            fail("Unterminated string");
370            return null;
371        }
372
373        private char unicodeEscape() {
374            if (offset + 4 > input.length()) fail("Incomplete unicode escape");
375            int value = 0;
376            for (int count = 0; count < 4; count++) {
377                char digit = input.charAt(offset++);
378                int hex = Character.digit(digit, 16);
379                if (hex < 0) fail("Invalid unicode escape");
380                value = (value << 4) | hex;
381            }
382            return (char) value;
383        }
384
385        private BigInteger integer() {
386            int start = offset;
387            if (take('-') && offset >= input.length()) fail("Incomplete number");
388            if (take('0')) {
389                if (offset < input.length() && isAsciiDigit(input.charAt(offset))) {
390                    fail("Leading zero in number");
391                }
392            } else {
393                if (offset >= input.length() || input.charAt(offset) < '1' || input.charAt(offset) > '9') {
394                    fail("Invalid number");
395                }
396                while (offset < input.length() && isAsciiDigit(input.charAt(offset))) offset++;
397            }
398            if (offset < input.length() && (input.charAt(offset) == '.' || input.charAt(offset) == 'e'
399                    || input.charAt(offset) == 'E')) {
400                fail("SLPC JSON numbers must be integers");
401            }
402            return new BigInteger(input.substring(start, offset));
403        }
404
405        private Object literal(String text, Object value) {
406            if (!input.regionMatches(offset, text, 0, text.length())) fail("Invalid literal");
407            offset += text.length();
408            return value;
409        }
410
411        private void skipWhitespace() {
412            while (offset < input.length()) {
413                char current = input.charAt(offset);
414                if (current != ' ' && current != '\n' && current != '\r' && current != '\t') return;
415                offset++;
416            }
417        }
418
419        private char peek() {
420            return offset < input.length() ? input.charAt(offset) : '\0';
421        }
422
423        private boolean take(char expected) {
424            if (offset < input.length() && input.charAt(offset) == expected) {
425                offset++;
426                return true;
427            }
428            return false;
429        }
430
431        private void require(char expected) {
432            if (!take(expected)) fail("Expected '" + expected + "'");
433        }
434
435        private void fail(String message) {
436            throw new SlpcJsonException(message + " at character " + offset);
437        }
438
439        private boolean isAsciiDigit(char value) {
440            return value >= '0' && value <= '9';
441        }
442    }
443}