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}