001package gudusoft.gsqlparser; 002 003import java.io.ByteArrayOutputStream; 004import java.io.IOException; 005import java.io.InputStream; 006 007/** 008 * Trial-build input size gate, measured ONCE, at the place the SQL is read. 009 * 010 * <h2>The rule</h2> 011 * 012 * <ul> 013 * <li>A byte-backed source (file, stream, FIFO, socket) is measured in RAW 014 * BYTES, before any decoding: at most {@code TBaseType.trialSizeLimitBytes()} 015 * bytes.</li> 016 * <li>A {@code String} input has no wire format, so it is measured in 017 * CHARACTERS, ignoring leading whitespace: at most the same limit.</li> 018 * </ul> 019 * 020 * <h2>Why characters for Strings, and why this needs no exemption machinery</h2> 021 * 022 * The parser re-parses its own already-ingested text in many places -- routine 023 * bodies, dynamic SQL, vendor retries; a survey found 73 such sub-parser sites. 024 * Those re-parses hand a {@code String} to a fresh parser, which measures it 025 * again. Every earlier revision of this gate therefore needed a way to tell 026 * "caller ingestion" from "internal re-parse" (explicit marks, then a 027 * ThreadLocal parse depth), and every such mechanism was itself a source of 028 * defects: a missed mark refused a valid 8,118-byte DB2 procedure; a 029 * UTF-16 function accepted at 6,968 raw bytes had its 10,283-UTF-8-byte body 030 * silently dropped. 031 * 032 * <p>Measuring Strings in characters removes the problem instead of managing 033 * it. Every charset encodes a character in at least one byte, so an input 034 * accepted under the byte cap has at most {@code limit} characters -- and any 035 * substring of it fewer still. Every internal re-parse of ingested text 036 * therefore passes arithmetically, with no marks, no thread state, and no list 037 * of sites to keep complete. Leading whitespace is ignored because internal 038 * re-parses prepend newlines/spaces to preserve the original line/column 039 * coordinates ({@code TBaseType.stringBlock}), and that padding is not input. 040 * 041 * <p>The tolerated cost runs in the harmless direction only: a caller-supplied 042 * String of multi-byte characters (e.g. 10,000 CJK characters, ~30KB as UTF-8) 043 * is accepted in a trial build. An oversized input slipping through a trial 044 * gate is a far smaller cost than a valid input being refused and its parse 045 * silently lost (owner decision 2026-07-28). 046 * 047 * <h2>Why raw bytes for byte-backed sources</h2> 048 * 049 * The cap used to be enforced by re-encoding already-decoded characters -- per 050 * line inside the lexer, per token inside the raw-statement extractor. That is 051 * not what "bytes" means, and it produced a fresh defect of the same family in 052 * every revision: a byte-order mark charged once per line inflated a valid 053 * 9,602-byte UTF-16 script to 10,080 and refused it; stateful ISO-2022 054 * encodings over-counted; charsets that can decode but not encode threw 055 * {@code UnsupportedOperationException}; a BOM stripped before counting let a 056 * 10,001-byte stream through; a cached lexer kept the previous input's charset 057 * and refused a valid 6,000-byte ASCII input. All of those are the same 058 * mistake. This class measures a byte source as it actually exists -- bytes, 059 * before any decoding -- so none of them can recur. 060 * 061 * <h2>Contract</h2> 062 * 063 * <ul> 064 * <li>A byte-backed source is read once into a bounded snapshot of at most 065 * {@code cap + 1} bytes. Reaching {@code cap + 1} means the input is over 066 * the cap and is refused; otherwise the snapshot IS the whole input and 067 * downstream consumes it, so the source is never read twice.</li> 068 * <li>An {@link IOException} while measuring is reported as an I/O failure, 069 * never as an accepted short input. A partial read that then fails must 070 * not look like a small legal input.</li> 071 * </ul> 072 * 073 * <p>Every method is a no-op in full builds, where {@code TBaseType.full_edition} 074 * is a compile-time constant and the guarded blocks are removed entirely. 075 */ 076public final class TrialInputGuard { 077 078 private TrialInputGuard() { 079 } 080 081 /** 082 * Outcome of measuring one input: allowed (optionally carrying the snapshot 083 * that downstream must consume in place of the original source), refused, or 084 * failed with I/O. 085 */ 086 public static final class Result { 087 private static final Result ALLOWED_NO_SNAPSHOT = new Result(null, null, null); 088 089 private final byte[] snapshot; 090 private final String refusalMessage; 091 private final IOException ioFailure; 092 093 private Result(byte[] snapshot, String refusalMessage, IOException ioFailure) { 094 this.snapshot = snapshot; 095 this.refusalMessage = refusalMessage; 096 this.ioFailure = ioFailure; 097 } 098 099 public boolean isRefused() { 100 return refusalMessage != null; 101 } 102 103 public boolean isIoFailure() { 104 return ioFailure != null; 105 } 106 107 /** Canonical cap message; null unless {@link #isRefused()}. */ 108 public String getRefusalMessage() { 109 return refusalMessage; 110 } 111 112 public IOException getIoFailure() { 113 return ioFailure; 114 } 115 116 /** 117 * Bytes already consumed from the source, which downstream MUST use in 118 * place of the original stream (the source has been read to EOF). Null 119 * when there is nothing to replace -- full builds, String input, or a 120 * refusal. 121 */ 122 public byte[] getSnapshot() { 123 return snapshot; 124 } 125 } 126 127 /** 128 * Measure a {@code String} input in characters, ignoring leading 129 * whitespace. Returns refused when the count exceeds the cap. Never 130 * consumes anything, and never re-encodes: characters, not bytes, are what 131 * keep internal re-parses of already-ingested text inside the cap (see the 132 * class comment). 133 */ 134 public static Result checkText(String sqlText) { 135 if (TBaseType.full_edition || sqlText == null || sqlText.isEmpty()) { 136 return Result.ALLOWED_NO_SNAPSHOT; 137 } 138 int start = 0; 139 while (start < sqlText.length() && Character.isWhitespace(sqlText.charAt(start))) { 140 start++; 141 } 142 if (TBaseType.trialSizeExceeded(sqlText.length() - start)) { 143 return new Result(null, TBaseType.trialSizeMessage(), null); 144 } 145 return Result.ALLOWED_NO_SNAPSHOT; 146 } 147 148 /** 149 * Measure a byte-backed source by reading at most {@code cap + 1} bytes. 150 * 151 * <p>On success the returned snapshot holds the ENTIRE input and the caller 152 * must use it instead of {@code source}, which is now at EOF. File length is 153 * deliberately NOT consulted: a file can change between a stat and a read, 154 * and reading the same stream that feeds decoding removes that race along 155 * with any disagreement between the two. 156 */ 157 public static Result checkStream(InputStream source) { 158 if (TBaseType.full_edition || source == null) { 159 return Result.ALLOWED_NO_SNAPSHOT; 160 } 161 int limit = TBaseType.trialSizeLimitBytes(); 162 ByteArrayOutputStream captured = new ByteArrayOutputStream(Math.min(limit + 1, 16384)); 163 byte[] chunk = new byte[4096]; 164 int total = 0; 165 try { 166 while (total <= limit) { 167 int want = Math.min(chunk.length, limit + 1 - total); 168 int read = source.read(chunk, 0, want); 169 if (read == -1) break; 170 captured.write(chunk, 0, read); 171 total += read; 172 } 173 } catch (IOException e) { 174 // A partial read that then failed is NOT a small legal input. 175 return new Result(null, null, e); 176 } 177 if (total > limit) { 178 return new Result(null, TBaseType.trialSizeMessage(), null); 179 } 180 return new Result(captured.toByteArray(), null, null); 181 } 182 183 /** 184 * Measure caller-supplied token text -- the one input with no source to read, 185 * reachable through {@code doExtractRawStatements}. Counted in characters 186 * like any other String input; the running total lets a huge token list 187 * fail fast without joining it. 188 */ 189 public static boolean tokenTextExceedsCap(TSourceTokenList tokens) { 190 if (TBaseType.full_edition || tokens == null) { 191 return false; 192 } 193 int limit = TBaseType.trialSizeLimitBytes(); 194 long total = 0; 195 for (int i = 0; i < tokens.size(); i++) { 196 TSourceToken st = tokens.get(i); 197 if (st == null) continue; 198 String text = st.getAstext(); 199 if (text == null) continue; 200 total += text.length(); 201 if (total > limit) { 202 return true; 203 } 204 } 205 return false; 206 } 207}