001package gudusoft.gsqlparser.lineage.v2.producer;
002
003import java.nio.ByteBuffer;
004import java.nio.charset.CharacterCodingException;
005import java.nio.charset.CodingErrorAction;
006import java.nio.charset.StandardCharsets;
007
008import gudusoft.gsqlparser.lineage.v2.SqllrResources;
009
010/**
011 * One source unit given to the SQL-LR producer: its identity and its text (specification Section 7.8).
012 *
013 * <ul>
014 *   <li>{@code NAMED}: the caller names the unit by {@code namespace} + {@code logicalName} (for example a repository
015 *   and a path relative to it). This is the portable identity; the content digest only identifies the version.</li>
016 *   <li>{@code CONTENT_ADDRESSED}: the caller declares that within {@code contextDomain} equal content is one
017 *   unit (for example entries of a query log).</li>
018 *   <li>{@code LOCAL}: everything else, such as an unnamed file; valid only within the document. A producer never
019 *   makes an unnamed file content-addressed by itself.</li>
020 * </ul>
021 *
022 * <p>The text is analysed exactly as decoded: no character is replaced, a byte order mark is removed and declared
023 * ({@code BOM_STRIPPED}), and line endings are kept ({@code PRESERVED}). Two inputs with equal text remain two
024 * units. Immutable.</p>
025 *
026 * @since 4.2.17
027 */
028public final class SqllrInput {
029
030    public static final String NAMED = "NAMED";
031    public static final String CONTENT_ADDRESSED = "CONTENT_ADDRESSED";
032    public static final String LOCAL = "LOCAL";
033
034    private final String identityKind;
035    private final String namespace;
036    private final String logicalName;
037    private final String contextDomain;
038    private final String text;
039    private final byte[] rawBytes;
040    private final String bom;
041
042    private SqllrInput(String identityKind, String namespace, String logicalName, String contextDomain, String text,
043            byte[] rawBytes, String bom) {
044        this.identityKind = identityKind;
045        this.namespace = namespace;
046        this.logicalName = logicalName;
047        this.contextDomain = contextDomain;
048        this.text = text;
049        this.rawBytes = rawBytes;
050        this.bom = bom;
051    }
052
053    /**
054     * A named unit from already-decoded text that is the whole content of the source, with no byte order mark
055     * ({@code NO_BOM}); no raw-bytes digest is published. Use {@link #named(String, String, String, String)} when
056     * the caller removed a byte order mark while decoding.
057     */
058    public static SqllrInput named(String namespace, String logicalName, String text) {
059        return named(namespace, logicalName, text, "NO_BOM");
060    }
061
062    /**
063     * A named unit from already-decoded text, declaring how a byte order mark was handled: {@code NO_BOM} (the raw
064     * bytes had none) or {@code BOM_STRIPPED} (the caller removed one; it is not part of the text).
065     */
066    public static SqllrInput named(String namespace, String logicalName, String text, String bom) {
067        checkNamespace(namespace);
068        checkLogicalName(logicalName);
069        return new SqllrInput(NAMED, namespace, logicalName, null, checkText(text), null, checkBom(bom));
070    }
071
072    /** A named unit from UTF-8 bytes; a leading byte order mark is removed and declared. */
073    public static SqllrInput named(String namespace, String logicalName, byte[] utf8) {
074        checkNamespace(namespace);
075        checkLogicalName(logicalName);
076        return fromBytes(NAMED, namespace, logicalName, null, utf8);
077    }
078
079    /** A content-addressed unit: the caller declares that equal content within the domain is one unit. */
080    public static SqllrInput contentAddressed(String contextDomain, String text) {
081        checkName("contextDomain", contextDomain);
082        return new SqllrInput(CONTENT_ADDRESSED, null, null, contextDomain, checkText(text), null, "NO_BOM");
083    }
084
085    /** A unit with no identity beyond the document; the text is the whole content, with no byte order mark. */
086    public static SqllrInput local(String text) {
087        return new SqllrInput(LOCAL, null, null, null, checkText(text), null, "NO_BOM");
088    }
089
090    /** A unit with no identity beyond the document, declaring the byte order mark handling. */
091    public static SqllrInput local(String text, String bom) {
092        return new SqllrInput(LOCAL, null, null, null, checkText(text), null, checkBom(bom));
093    }
094
095    /** A unit with no identity beyond the document, from UTF-8 bytes. */
096    public static SqllrInput local(byte[] utf8) {
097        return fromBytes(LOCAL, null, null, null, utf8);
098    }
099
100    private static SqllrInput fromBytes(String kind, String namespace, String logicalName, String domain, byte[] utf8) {
101        if (utf8 == null) {
102            throw new IllegalArgumentException("no bytes");
103        }
104        byte[] raw = utf8.clone();
105        int start = 0;
106        String bom = "NO_BOM";
107        if (raw.length >= 3 && (raw[0] & 0xFF) == 0xEF && (raw[1] & 0xFF) == 0xBB && (raw[2] & 0xFF) == 0xBF) {
108            start = 3;
109            bom = "BOM_STRIPPED";
110        }
111        String text;
112        try {
113            text = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT)
114                    .onUnmappableCharacter(CodingErrorAction.REPORT)
115                    .decode(ByteBuffer.wrap(raw, start, raw.length - start)).toString();
116        } catch (CharacterCodingException e) {
117            throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "the input is not valid UTF-8", e);
118        }
119        return new SqllrInput(kind, namespace, logicalName, domain, text, raw, bom);
120    }
121
122    private static String checkText(String text) {
123        if (text == null) {
124            throw new IllegalArgumentException("no text");
125        }
126        for (int i = 0; i < text.length(); i++) {
127            char c = text.charAt(i);
128            if (Character.isHighSurrogate(c) && i + 1 < text.length() && Character.isLowSurrogate(text.charAt(i + 1))) {
129                i++;
130            } else if (Character.isSurrogate(c)) {
131                throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "text has an unpaired surrogate at " + i);
132            }
133        }
134        if (!text.isEmpty() && text.charAt(0) == '') {
135            throw new SqllrProducerException("PRODUCER_INVALID_INPUT",
136                    "decoded text starts with U+FEFF; give the raw bytes so the byte order mark is declared");
137        }
138        return text;
139    }
140
141    private static String checkBom(String bom) {
142        if (!"NO_BOM".equals(bom) && !"BOM_STRIPPED".equals(bom)) {
143            throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "bom must be NO_BOM or BOM_STRIPPED, not " + bom);
144        }
145        return bom;
146    }
147
148    /**
149     * A namespace names a repository or similar logical source, never a place on a disk (specification Section
150     * 7.8): an absolute path, a drive, a backslash or a {@code file:} URI is refused.
151     */
152    static void checkNamespace(String namespace) {
153        checkName("namespace", namespace);
154        if (isLocalLocator(namespace)) {
155            throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "namespace must be a logical name, not a local "
156                    + "path: " + namespace);
157        }
158    }
159
160    private static boolean isLocalLocator(String value) {
161        String lower = value.toLowerCase(java.util.Locale.ROOT);
162        return value.startsWith("/") || value.startsWith("~") || value.indexOf('\\') >= 0
163                || (value.length() >= 2 && value.charAt(1) == ':' && Character.isLetter(value.charAt(0)))
164                || lower.startsWith("file:");
165    }
166
167    private static void checkName(String what, String value) {
168        if (value == null || value.isEmpty()) {
169            throw new SqllrProducerException("PRODUCER_INVALID_INPUT", what + " is empty");
170        }
171    }
172
173    /**
174     * The rules the M1 source-text reader applies to a logical name, plus the ones that keep local paths out of
175     * an identity: relative, '/'-separated, no empty, '.' or '..' segment, no trailing '/', no backslash, no drive.
176     */
177    static void checkLogicalName(String name) {
178        checkName("logicalName", name);
179        if (isLocalLocator(name) || name.endsWith("/") || name.matches("^[A-Za-z][A-Za-z0-9+.-]*:.*")) {
180            throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "logicalName must be a relative path: " + name);
181        }
182        for (String segment : name.split("/", -1)) {
183            if (segment.isEmpty() || ".".equals(segment) || "..".equals(segment)) {
184                throw new SqllrProducerException("PRODUCER_INVALID_INPUT", "logicalName has an empty, '.' or '..' "
185                        + "segment: " + name);
186            }
187        }
188    }
189
190    public String getIdentityKind() {
191        return identityKind;
192    }
193
194    public String getNamespace() {
195        return namespace;
196    }
197
198    public String getLogicalName() {
199        return logicalName;
200    }
201
202    public String getContextDomain() {
203        return contextDomain;
204    }
205
206    /** The decoded text the analysis reads. */
207    public String getText() {
208        return text;
209    }
210
211    /** {@code NO_BOM} or {@code BOM_STRIPPED}. */
212    public String getBom() {
213        return bom;
214    }
215
216    /** The raw bytes, when given; a copy. */
217    public byte[] getRawBytes() {
218        return rawBytes == null ? null : rawBytes.clone();
219    }
220
221    /** SHA-256 of the raw bytes ({@code RAW_BYTES_SHA256}), or {@code null} when only text was given. */
222    public String rawBytesDigest() {
223        return rawBytes == null ? null : SqllrResources.sha256Hex(rawBytes);
224    }
225
226    /** SHA-256 of the UTF-8 encoding of the decoded text ({@code DECODED_TEXT_SHA256}). */
227    public String decodedTextDigest() {
228        return SqllrResources.sha256Hex(text.getBytes(StandardCharsets.UTF_8));
229    }
230
231    @Override
232    public String toString() {
233        return identityKind + (namespace != null ? " " + namespace + " " + logicalName : "")
234                + (contextDomain != null ? " " + contextDomain : "") + " (" + text.length() + " chars)";
235    }
236
237    /** Two inputs are equal only when they are the same object: equal text never merges two units. */
238    @Override
239    public boolean equals(Object other) {
240        return this == other;
241    }
242
243    @Override
244    public int hashCode() {
245        return System.identityHashCode(this);
246    }
247}