001/*
002 * Copyright 2015-2024 the original author or authors
003 *
004 * This software is licensed under the Apache License, Version 2.0,
005 * the GNU Lesser General Public License version 2 or later ("LGPL")
006 * and the WTFPL.
007 * You may choose either license to govern your use of this software only
008 * upon the condition that you accept all of the terms of either
009 * the Apache License 2.0, the LGPL 2.1+ or the WTFPL.
010 */
011package org.minidns.dnslabel;
012
013import java.io.ByteArrayOutputStream;
014import java.nio.charset.StandardCharsets;
015import java.util.Locale;
016
017import org.minidns.util.SafeCharSequence;
018
019/**
020 * A DNS label is an individual component of a DNS name. Labels are usually shown separated by dots.
021 * <p>
022 * This class implements {@link Comparable} which compares DNS labels according to the Canonical DNS Name Order as
023 * specified in <a href="https://tools.ietf.org/html/rfc4034#section-6.1">RFC 4034 § 6.1</a>.
024 * </p>
025 * <p>
026 * Note that as per <a href="https://tools.ietf.org/html/rfc2181#section-11">RFC 2181 § 11</a> DNS labels may contain
027 * any byte.
028 * </p>
029 * 
030 * @see <a href="https://tools.ietf.org/html/rfc5890#section-2.2">RFC 5890 § 2.2. DNS-Related Terminology</a>
031 * @author Florian Schmaus
032 *
033 */
034public abstract class DnsLabel extends SafeCharSequence implements Comparable<DnsLabel> {
035
036    /**
037     * The maximum length of a DNS label in octets.
038     *
039     * @see <a href="https://tools.ietf.org/html/rfc1035">RFC 1035 § 2.3.4.</a>
040     */
041    public static final int MAX_LABEL_LENGTH_IN_OCTETS = 63;
042
043    public static final DnsLabel WILDCARD_LABEL = DnsLabel.from("*");
044
045    /**
046     * Whether or not the DNS label is validated on construction.
047     */
048    public static boolean VALIDATE = true;
049
050    public final String label;
051
052    protected DnsLabel(String label) {
053        this.label = label;
054
055        if (!VALIDATE) {
056            return;
057        }
058
059        setBytesIfRequired();
060        if (byteCache.length > MAX_LABEL_LENGTH_IN_OCTETS) {
061            throw new LabelToLongException(label);
062        }
063    }
064
065    private transient String internationalizedRepresentation;
066
067    public final String getInternationalizedRepresentation() {
068        if (internationalizedRepresentation == null) {
069            internationalizedRepresentation = getInternationalizedRepresentationInternal();
070        }
071        return internationalizedRepresentation;
072    }
073
074    protected String getInternationalizedRepresentationInternal() {
075        return label;
076    }
077
078    public final String getLabelType() {
079        return getClass().getSimpleName();
080    }
081
082    private transient String safeToStringRepresentation;
083
084    @Override
085    public final String toString() {
086        if (safeToStringRepresentation == null) {
087            safeToStringRepresentation = toSafeRepesentation(label);
088        }
089
090        return safeToStringRepresentation;
091    }
092
093    /**
094     * Get the raw label. Note that this may return a String containing null bytes.
095     * Those Strings are notoriously difficult to handle from a security
096     * perspective. Therefore it is recommended to use {@link #toString()} instead,
097     * which will return a sanitized String.
098     *
099     * @return the raw label.
100     * @since 1.1.0
101     */
102    public final String getRawLabel() {
103        return label;
104    }
105
106    @Override
107    public final boolean equals(Object other) {
108        if (!(other instanceof DnsLabel)) {
109            return false;
110        }
111        DnsLabel otherDnsLabel = (DnsLabel) other;
112        return label.equals(otherDnsLabel.label);
113    }
114
115    @Override
116    public final int hashCode() {
117        return label.hashCode();
118    }
119
120    private transient DnsLabel lowercasedVariant;
121
122    public final DnsLabel asLowercaseVariant() {
123        if (lowercasedVariant == null) {
124            String lowercaseLabel = label.toLowerCase(Locale.US);
125            lowercasedVariant = DnsLabel.from(lowercaseLabel);
126        }
127        return lowercasedVariant;
128    }
129
130    private transient byte[] byteCache;
131
132    private void setBytesIfRequired() {
133        if (byteCache == null) {
134            byteCache = label.getBytes(StandardCharsets.US_ASCII);
135        }
136    }
137
138    public final void writeToBoas(ByteArrayOutputStream byteArrayOutputStream) {
139        setBytesIfRequired();
140
141        byteArrayOutputStream.write(byteCache.length);
142        byteArrayOutputStream.write(byteCache, 0, byteCache.length);
143    }
144
145    @Override
146    public final int compareTo(DnsLabel other) {
147        String myCanonical = asLowercaseVariant().label;
148        String otherCanonical = other.asLowercaseVariant().label;
149
150        return myCanonical.compareTo(otherCanonical);
151    }
152
153    public static DnsLabel from(String label) {
154        if (label == null || label.isEmpty()) {
155            throw new IllegalArgumentException("Label is null or empty");
156        }
157
158        if (LdhLabel.isLdhLabel(label)) {
159            return LdhLabel.fromInternal(label);
160        }
161
162        return NonLdhLabel.fromInternal(label);
163    }
164
165    public static DnsLabel[] from(String[] labels) {
166        DnsLabel[] res = new DnsLabel[labels.length];
167
168        for (int i = 0; i < labels.length; i++) {
169            res[i] = DnsLabel.from(labels[i]);
170        }
171
172        return res;
173    }
174
175    public static boolean isIdnAcePrefixed(String string) {
176        return string.toLowerCase(Locale.US).startsWith("xn--");
177    }
178
179    public static String toSafeRepesentation(String dnsLabel) {
180        if (consistsOnlyOfLettersDigitsHypenAndUnderscore(dnsLabel)) {
181            // This label is safe, nothing to do.
182            return dnsLabel;
183        }
184
185        StringBuilder sb = new StringBuilder(2 * dnsLabel.length());
186        for (int i = 0; i < dnsLabel.length(); i++) {
187            char c = dnsLabel.charAt(i);
188            if (isLdhOrMaybeUnderscore(c, true)) {
189                sb.append(c);
190                continue;
191            }
192
193
194            // Let's see if we found and unsafe char we want to replace.
195            switch (c) {
196            case '.':
197                sb.append('●'); // U+25CF BLACK CIRCLE;
198                break;
199            case '\\':
200                sb.append('⧷'); // U+29F7 REVERSE SOLIDUS WITH HORIZONTAL STROKE
201                break;
202            case '\u007f':
203                // Convert DEL to U+2421 SYMBOL FOR DELETE
204                sb.append('␡');
205                break;
206            case ' ':
207                sb.append('␣'); // U+2423 OPEN BOX
208                break;
209            default:
210                if (c < 32) {
211                    // First convert the ASCI control codes to the Unicode Control Pictures
212                    int substituteAsInt = c + '\u2400';
213                    assert substituteAsInt <= Character.MAX_CODE_POINT;
214                    char substitute = (char) substituteAsInt;
215                    sb.append(substitute);
216                } else if (c < 127) {
217                    // Everything smaller than 127 is now safe to directly append.
218                    sb.append(c);
219                } else if (c > 255) {
220                    throw new IllegalArgumentException("The string '" + dnsLabel
221                            + "' contains characters outside the 8-bit range: " + c + " at position " + i);
222                } else {
223                    // Everything that did not match the previous conditions is explicitly escaped.
224                    sb.append("〚"); // U+301A
225                    // Transform the char to hex notation. Note that we have ensure that c is <= 255
226                    // here, hence only two hexadecimal places are ok.
227                    String hex = String.format("%02X", (int) c);
228                    sb.append(hex);
229                    sb.append("〛"); // U+301B
230                }
231            }
232        }
233
234        return sb.toString();
235    }
236
237    private static boolean isLdhOrMaybeUnderscore(char c, boolean underscore) {
238            // CHECKSTYLE:OFF
239            return (c >= 'a' && c <= 'z')
240                    || (c >= 'A' && c <= 'Z')
241                    || (c >= '0' && c <= '9')
242                    || c == '-'
243                    || (underscore && c == '_')
244                    ;
245            // CHECKSTYLE:ON
246    }
247
248    private static boolean consistsOnlyOfLdhAndMaybeUnderscore(String string, boolean underscore) {
249        for (int i = 0; i < string.length(); i++) {
250            char c = string.charAt(i);
251            if (isLdhOrMaybeUnderscore(c, underscore)) {
252                continue;
253            }
254            return false;
255        }
256        return true;
257    }
258
259    public static boolean consistsOnlyOfLettersDigitsAndHypen(String string) {
260        return consistsOnlyOfLdhAndMaybeUnderscore(string, false);
261    }
262
263    public static boolean consistsOnlyOfLettersDigitsHypenAndUnderscore(String string) {
264        return consistsOnlyOfLdhAndMaybeUnderscore(string, true);
265    }
266
267    public static class LabelToLongException extends IllegalArgumentException {
268
269        /**
270         *
271         */
272        private static final long serialVersionUID = 1L;
273
274        public final String label;
275
276        LabelToLongException(String label) {
277            this.label = label;
278        }
279    }
280}