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}