001/*
002 * Copyright (C) 2026 The Guava Authors
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
005 * in compliance with the License. You may obtain a copy of the License at
006 *
007 * http://www.apache.org/licenses/LICENSE-2.0
008 *
009 * Unless required by applicable law or agreed to in writing, software distributed under the License
010 * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
011 * or implied. See the License for the specific language governing permissions and limitations under
012 * the License.
013 */
014
015package com.google.thirdparty.publicsuffix;
016
017import static java.lang.Math.min;
018
019import com.google.common.annotations.GwtCompatible;
020import com.google.common.collect.ImmutableList;
021import java.util.List;
022import org.jspecify.annotations.Nullable;
023
024/**
025 * A memory-efficient binary trie structure for public suffix lookups. The trie is stored as a
026 * series of 16-bit characters in a String.
027 *
028 * <p>The trie data is stored in a {@link CharSequence} where each node takes 3 characters:
029 *
030 * <ol>
031 *   <li>The offset of the label in the {@code stringPool}.
032 *   <li>The index of the first child node in the {@code trieData}.
033 *   <li>A bitfield containing:
034 *       <ul>
035 *         <li>Bits 0-10: Number of children.
036 *         <li>Bit 11: Whether this node represents an exclusion (!rule).
037 *         <li>Bits 12-13: The type of wildcard match (*.rule) at this node (0=none, 1=REGISTRY,
038 *             2=PRIVATE).
039 *         <li>Bits 14-15: The type of exact match (rule) at this node (0=none, 1=REGISTRY,
040 *             2=PRIVATE).
041 *       </ul>
042 * </ol>
043 *
044 * <p>The {@code stringPool} stores the labels of the nodes as length-prefixed strings.
045 *
046 * <p><b>Capacity Limits and Scalability:</b>
047 *
048 * <ul>
049 *   <li><b>Total nodes:</b> 65535 (16-bit index in the trie)
050 *   <li><b>String pool size:</b> 65535 characters (16-bit offset in the String Pool)
051 *   <li><b>Max children per node:</b> 2047 (11-bit count in the bitfield)
052 * </ul>
053 *
054 * <p>As of Jan 2026, the capacity usage is approximately:
055 *
056 * <ul>
057 *   <li>Total nodes: ~16% (10k / 65k)
058 *   <li>String pool size: ~79% (52k / 65k)
059 *   <li>Max children per node: ~71% (1.5k / 2k)
060 * </ul>
061 *
062 * <p>If any of these limits are exceeded, the definitive solution is to increase the node size from
063 * 3 to 4 characters. This would allow:
064 *
065 * <ul>
066 *   <li>A full 32-bit offset for the String Pool (using 2 characters).
067 *   <li>A full 16-bit child count (using the freed bits from the 4th character).
068 * </ul>
069 */
070@GwtCompatible
071public final class PublicSuffixTrie {
072
073  /** Each node in the trie takes up 3 characters. */
074  static final int NODE_SIZE = 3;
075
076  static final int CHILDREN_MASK = 0x7FF;
077  static final int EXCLUSION_MASK = 0x1;
078  static final int TYPE_MASK = 0x3;
079  static final int EXCLUSION_SHIFT = 11;
080  static final int WILDCARD_SHIFT = 12;
081  static final int EXACT_SHIFT = 14;
082
083  final CharSequence trieData;
084  final CharSequence stringPool;
085
086  PublicSuffixTrie(
087      ImmutableList<CharSequence> trieDataChunks,
088      ImmutableList<CharSequence> stringPoolChunks,
089      int chunkShift) {
090    this.trieData = new ChunksCharSequence(trieDataChunks, chunkShift);
091    this.stringPool = new ChunksCharSequence(stringPoolChunks, chunkShift);
092  }
093
094  /**
095   * Performs a lookup for the public suffix of the given labels, considering all public suffix
096   * types. Returns the index of the leftmost label in the public suffix, or -1 if no match found.
097   *
098   * @param labels the domain labels, from left to right.
099   * @return the index of the leftmost label in the public suffix, or -1 if not found.
100   */
101  public int findSuffixIndex(List<String> labels) {
102    return findSuffixIndex(labels, /* desiredType= */ null);
103  }
104
105  /**
106   * Performs a lookup for the public suffix of the given labels and returns the index of the
107   * leftmost label in the public suffix. Returns -1 if no match found.
108   *
109   * @param labels the domain labels, from left to right.
110   * @param desiredType the desired type of public suffix (e.g. REGISTRY or PRIVATE). If null, any
111   *     {@link PublicSuffixType} will be considered a match.
112   * @return the index of the leftmost label in the public suffix, or -1 if not found.
113   */
114  public int findSuffixIndex(List<String> labels, @Nullable PublicSuffixType desiredType) {
115    int partsSize = labels.size();
116    int nodeIndex = 0; // Root node.
117    int bestResult = -1;
118
119    // Iterate over the labels in reverse order (from top level domain to the left).
120    for (int i = partsSize - 1; i >= 0; i--) {
121      // Decode the current node.
122      int firstChild = trieData.charAt(nodeIndex * NODE_SIZE + 1);
123      int numChildren = trieData.charAt(nodeIndex * NODE_SIZE + 2) & CHILDREN_MASK;
124
125      nodeIndex = findChild(firstChild, numChildren, labels.get(i));
126      if (nodeIndex == -1) {
127        break;
128      }
129
130      // Check for matches at this node. Higher priority rules are checked last to overwrite.
131      int metadata = trieData.charAt(nodeIndex * NODE_SIZE + 2);
132
133      // Excluded match (!rule). If this bit is set, it means the current path is NOT a public
134      // suffix, even if a previous rule matched. For example, "!www.ck" would exclude "www.ck"
135      // from being a public suffix, leaving "ck" as the suffix.
136      if (isExcludedMatch(metadata)) {
137        bestResult = i + 1;
138      }
139
140      // Exact match. If the exact type bits are set, this node itself is a public suffix.
141      PublicSuffixType exactType = getExactMatchType(metadata);
142      if (matchesType(desiredType, exactType)) {
143        bestResult = i;
144      }
145
146      // Wildcard match (*.rule). If the wildcard type bits are set, any child of this node is a
147      // public suffix. For example, "*.ck" means "any.ck" is a public suffix.
148      PublicSuffixType wildcardType = getWildcardMatchType(metadata);
149      if (i > 0 && matchesType(desiredType, wildcardType)) {
150        bestResult = i - 1;
151      }
152    }
153    return bestResult;
154  }
155
156  private static boolean matchesType(
157      @Nullable PublicSuffixType desiredType, @Nullable PublicSuffixType actualType) {
158    return actualType != null && (desiredType == null || desiredType.equals(actualType));
159  }
160
161  private static boolean isExcludedMatch(int metadata) {
162    return ((metadata >> EXCLUSION_SHIFT) & EXCLUSION_MASK) != 0;
163  }
164
165  private @Nullable PublicSuffixType getExactMatchType(int metadata) {
166    return getType((metadata >> EXACT_SHIFT) & TYPE_MASK);
167  }
168
169  private @Nullable PublicSuffixType getWildcardMatchType(int metadata) {
170    return getType((metadata >> WILDCARD_SHIFT) & TYPE_MASK);
171  }
172
173  private @Nullable PublicSuffixType getType(int typeBits) {
174    if (typeBits == 1) {
175      return PublicSuffixType.REGISTRY;
176    } else if (typeBits == 2) {
177      return PublicSuffixType.PRIVATE;
178    }
179    return null;
180  }
181
182  /**
183   * Performs a search over the children of a node to find the child with the given label.
184   *
185   * @return the index of the child node if found, or -1 otherwise.
186   */
187  private int findChild(int firstChild, int numChildren, String label) {
188    int low = firstChild;
189    int high = firstChild + numChildren - 1;
190
191    while (low <= high) {
192      int mid = (low + high) >>> 1;
193      int labelOffset = trieData.charAt(mid * NODE_SIZE);
194      int cmp = compareLabel(label, labelOffset);
195
196      if (cmp < 0) {
197        high = mid - 1;
198      } else if (cmp > 0) {
199        low = mid + 1;
200      } else {
201        return mid;
202      }
203    }
204    return -1;
205  }
206
207  /**
208   * Returns a negative integer, zero, or a positive integer as the specified label is
209   * lexicographically less than, equal to, or greater than the label in the string pool.
210   */
211  private int compareLabel(String label, int offset) {
212    int labelLen = label.length();
213    int nodeLabelLen = stringPool.charAt(offset);
214    int minLen = min(nodeLabelLen, labelLen);
215    for (int i = 0; i < minLen; i++) {
216      char c1 = label.charAt(i);
217      char c2 = stringPool.charAt(offset + 1 + i);
218      if (c1 != c2) {
219        return c1 - c2;
220      }
221    }
222    return labelLen - nodeLabelLen;
223  }
224
225  /**
226   * A CharSequence that is composed of multiple chunks. This is used to work around the 64k size
227   * limit for string literals in Java.
228   */
229  private static class ChunksCharSequence implements CharSequence {
230    private final ImmutableList<CharSequence> chunks;
231    private final int length;
232    private final int chunkShift;
233    private final int chunkMask;
234
235    ChunksCharSequence(ImmutableList<CharSequence> chunks, int chunkShift) {
236      this.chunks = chunks;
237      this.chunkShift = chunkShift;
238      this.chunkMask = (1 << chunkShift) - 1;
239      int total = 0;
240      for (CharSequence element : chunks) {
241        total += element.length();
242      }
243      this.length = total;
244    }
245
246    @Override
247    public int length() {
248      return length;
249    }
250
251    @Override
252    public char charAt(int index) {
253      return chunks.get(index >> chunkShift).charAt(index & chunkMask);
254    }
255
256    @Override
257    public CharSequence subSequence(int start, int end) {
258      throw new UnsupportedOperationException();
259    }
260
261    @Override
262    public String toString() {
263      StringBuilder sb = new StringBuilder(length);
264      for (CharSequence chunk : chunks) {
265        sb.append(chunk);
266      }
267      return sb.toString();
268    }
269  }
270}