Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ This format is based on [Keep a Changelog](http://keepachangelog.com/).
This project does not adhere to [Semantic Versioning](https://semver.org/) and minor version changes can have incompatible API changes. These incompatible API changes will largely affect those who have custom validator or walker implementations. Those who just use the library to validate using the standard JSON Schema Draft specifications may not need changes.
## [Unreleased]

### Changed

- `hostname` rejects names longer than 253 characters.
- `hostname` checks a label that starts with `xn--`, in any case, as an A-label. It must be at most 63 characters of Punycode that encodes back to itself, and its U-label must pass the IDNA2008 rules listed below. `xn--X`, `xn--hello-txk` and `xn--1-0hc` (U-label `1א`, which breaks the RFC 5893 Bidi Rule) are now rejected; before, any LDH label passed. The Bidi Rule applies within each A-label only: the other labels stay RFC 1123 LDH labels, so `3com.xn--mgbaam7a8h` remains valid.
- `idn-hostname` decodes A-labels with an RFC 3492 Punycode decoder instead of `java.net.IDN` (IDNA2003). A-labels with characters IDNA2008 allows in context, such as U+00DF, U+03C2, U+200C and U+200D, are accepted when the RFC 5892 rules allow them. A-labels are case-insensitive.
- `idn-hostname` measures the 63-character label limit in A-label form for U-labels too, and limits names to 253 characters in A-label form.
- `idn-hostname` rejects more than one trailing label separator, such as `a..` and `xn--4dbc..`; before, any number was accepted. A single trailing separator is still accepted and does not count toward the 253 characters.
- IDNA2008 rules, for both formats:
- Hyphen positions are counted in code points, and each supplementary code point is tested once rather than as two surrogates.
- ZWJ and ZWNJ are accepted after any virama (Canonical_Combining_Class 9, as of Unicode 16.0), not only U+094D. The ZWNJ joining-type check steps over supplementary transparent marks.
- The KATAKANA MIDDLE DOT, GREEK LOWER NUMERAL SIGN and Hebrew GERESH/GERSHAYIM rules use the Unicode script rather than code blocks. KATAKANA MIDDLE DOT accepts a Hiragana, Katakana or Han character anywhere in the label (RFC 5892 A.7).
- RFC 5893 Bidi Rule: a label with an R, AL or AN character must satisfy the rule, so `0ا` is an invalid `idn-hostname` and `xn--1-0hc` (`1א`) an invalid `hostname` label. In `idn-hostname`, every other label of such a name must satisfy it too, so `1a.אב` and `1a.xn--4dbc` are invalid there.
- Following RFC 6452, U+0CF1 and U+0CF2 are valid and U+19DA is not, overriding the bundled table.
- The Unicode tables load safely across threads.
- Code points added after Unicode 5.2: the bundled RFC 5892 Appendix B table predates them, so they are classified with the running JDK's Unicode data, approximating the RFC 5892 derivation. Letters, marks and digits that are stable under NFKC and case folding are accepted, such as Adlam small letters and new Cherokee capitals. Emoji, symbols, upper case, format controls, letters that fold to another letter (U+1C80) and the default-ignorable U+180F are rejected, as are Cherokee small letters.
- Known limits:
- Verdicts for code points newer than the JDK's Unicode version depend on the JDK. For example, a Unicode 14 letter is rejected on Java 17 (Unicode 13) and accepted on Java 21.
- The virama list stops at Unicode 16.0.
- `idn-hostname` does not apply UTS 46 mapping, and still accepts a single trailing label separator (`example.`).
- Removed the unused `UnicodeDatabase.isGreek`, `isHebrew` and `isKatakana`, which tested code blocks rather than scripts.
- Updated the vendored `hostname.json` and `idn-hostname.json` suite files to the current JSON-Schema-Test-Suite, including `draft-next`. Cases that need UTS 46 mapping or trailing-separator rejection are disabled with a reason.

## 3.0.9- 2026-11-03

### Breaking changes
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/networknt/schema/format/Formats.java
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ static PatternFormat pattern(String name, String regex) {
static {
List<Format> formats = new ArrayList<>();

formats.add(pattern("hostname", "^([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])(\\.([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9]))*$", "format.hostname"));
formats.add(new HostnameFormat());
formats.add(pattern("ipv4", "^(([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$", "format.ipv4"));
formats.add(new IPv6Format());
formats.add(pattern("json-pointer", "^(/([^/#~]|[~](?=[01]))*)*$", "format.json-pointer"));
Expand Down
55 changes: 55 additions & 0 deletions src/main/java/com/networknt/schema/format/HostnameFormat.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.networknt.schema.format;

import java.util.regex.Pattern;

import com.networknt.schema.ExecutionContext;
import com.networknt.schema.utils.RFC5892;

/**
* Format for hostname.
* <p>
* A hostname is a sequence of LDH labels (RFC 1123, section 2.1) of at most 63 characters
* each, separated by dots, with an overall length of at most 253 characters. A label that
* starts with the ACE prefix {@code xn--}, in any case, is an A-label and must also be valid
* Punycode that decodes to a valid U-label (RFC 5890, section 2.3.2.1); see
* {@link RFC5892#isValidALabel(String)}. The RFC 5893 Bidi Rule applies within each A-label
* only: the other labels are RFC 1123 LDH labels, so a digit-first label such as {@code 3com} stays
* valid beside a right-to-left A-label. The cross-label Bidi Rule applies to {@code idn-hostname}.
*/
public class HostnameFormat implements Format {
private static final int MAX_LENGTH = 253;

private static final String LABEL = "[a-zA-Z0-9]([a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])?";

private static final Pattern PATTERN = Pattern.compile(LABEL + "(\\." + LABEL + ")*");

private static final String ACE_PREFIX = "xn--";

@Override
public boolean matches(ExecutionContext executionContext, String value) {
if (null == value) return true;
if (value.length() > MAX_LENGTH || !PATTERN.matcher(value).matches()) {
return false;
}
// Walk the labels without splitting, so only an A-label is copied out of the value.
for (int start = 0; start < value.length(); ) {
int end = value.indexOf('.', start);
if (end < 0) end = value.length();
if (value.regionMatches(true, start, ACE_PREFIX, 0, ACE_PREFIX.length())) {
if (!RFC5892.isValidALabel(value.substring(start, end))) return false;
}
start = end + 1;
}
return true;
}

@Override
public String getName() {
return "hostname";
}

@Override
public String getMessageKey() {
return "format.hostname";
}
}
139 changes: 139 additions & 0 deletions src/main/java/com/networknt/schema/utils/Punycode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package com.networknt.schema.utils;

/**
* Punycode (RFC 3492) for IDNA2008 A-labels.
* <p>
* {@link java.net.IDN} implements IDNA2003, which maps or rejects code points that IDNA2008
* allows, such as U+00DF (sharp s), U+03C2 (final sigma), U+200C (ZWNJ) and U+200D (ZWJ). Decoding
* the Punycode directly leaves those code points for the RFC 5892 rules to judge.
*/
final class Punycode {
private static final int BASE = 36;
private static final int TMIN = 1;
private static final int TMAX = 26;
private static final int SKEW = 38;
private static final int DAMP = 700;
private static final int INITIAL_BIAS = 72;
private static final int INITIAL_N = 0x80;
private static final char DELIMITER = '-';

private Punycode() {
}

/**
* Decodes a Punycode string (an A-label without its {@code xn--} prefix).
*
* @param input the Punycode string
* @return the decoded string, or {@code null} if the input is not valid Punycode
*/
static String decode(String input) {
int b = input.lastIndexOf(DELIMITER);
if (b < 0) b = 0;
// Every decoded code point consumes at least one input character, so this never overflows.
int[] output = new int[input.length()];
int length = 0;
for (int j = 0; j < b; j++) {
char c = input.charAt(j);
if (c >= 0x80) return null;
output[length++] = c;
}
int n = INITIAL_N;
int bias = INITIAL_BIAS;
int i = 0;
for (int in = b > 0 ? b + 1 : 0; in < input.length(); ) {
int oldi = i;
int w = 1;
for (int k = BASE; ; k += BASE) {
if (in >= input.length()) return null;
int digit = digit(input.charAt(in++));
if (digit < 0) return null;
if (digit > (Integer.MAX_VALUE - i) / w) return null;
i += digit * w;
int t = k <= bias ? TMIN : k >= bias + TMAX ? TMAX : k - bias;
if (digit < t) break;
if (w > Integer.MAX_VALUE / (BASE - t)) return null;
w *= BASE - t;
}
bias = adapt(i - oldi, length + 1, oldi == 0);
if (i / (length + 1) > Integer.MAX_VALUE - n) return null;
n += i / (length + 1);
i %= length + 1;
if (n > Character.MAX_CODE_POINT || (n >= Character.MIN_SURROGATE && n <= Character.MAX_SURROGATE)) {
return null;
}
System.arraycopy(output, i, output, i + 1, length - i);
output[i++] = n;
length++;
}
return new String(output, 0, length);
}

/**
* Encodes a string as Punycode (without the {@code xn--} prefix).
*
* @param input the string to encode
* @return the Punycode string
*/
static String encode(String input) {
int[] codePoints = input.codePoints().toArray();
StringBuilder output = new StringBuilder();
for (int c : codePoints) {
if (c < 0x80) output.append((char) c);
}
int b = output.length();
int h = b;
if (b > 0) output.append(DELIMITER);
int n = INITIAL_N;
int delta = 0;
int bias = INITIAL_BIAS;
while (h < codePoints.length) {
int m = Integer.MAX_VALUE;
for (int c : codePoints) {
if (c >= n && c < m) m = c;
}
delta += (m - n) * (h + 1);
n = m;
for (int c : codePoints) {
if (c < n) delta++;
if (c == n) {
int q = delta;
for (int k = BASE; ; k += BASE) {
int t = k <= bias ? TMIN : k >= bias + TMAX ? TMAX : k - bias;
if (q < t) break;
output.append(encodeDigit(t + (q - t) % (BASE - t)));
q = (q - t) / (BASE - t);
}
output.append(encodeDigit(q));
bias = adapt(delta, h + 1, h == b);
delta = 0;
h++;
}
}
delta++;
n++;
}
return output.toString();
}

private static int adapt(int delta, int numPoints, boolean firstTime) {
delta = firstTime ? delta / DAMP : delta / 2;
delta += delta / numPoints;
int k = 0;
while (delta > ((BASE - TMIN) * TMAX) / 2) {
delta /= BASE - TMIN;
k += BASE;
}
return k + (BASE - TMIN + 1) * delta / (delta + SKEW);
}

private static int digit(char c) {
if (c >= '0' && c <= '9') return c - '0' + 26;
if (c >= 'a' && c <= 'z') return c - 'a';
if (c >= 'A' && c <= 'Z') return c - 'A';
return -1;
}

private static char encodeDigit(int d) {
return (char) (d < 26 ? 'a' + d : '0' + d - 26);
}
}
Loading