Skip to content
Merged
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
105 changes: 91 additions & 14 deletions src/main/java/net/datafaker/internal/helper/FakerIDN.java
Original file line number Diff line number Diff line change
@@ -1,32 +1,109 @@
package net.datafaker.internal.helper;

import java.net.IDN;
import java.util.Locale;
import org.jspecify.annotations.Nullable;

/**
* Created by tshick on 10/30/16.
* Helper for converting domain names to ASCII using Punycode (IDN).
* <p>
* This class wraps {@link IDN#toASCII(String)} with fallback handling for edge cases
* (e.g., bidirectional text in Farsi and Hebrew). The output is guaranteed to be a
* valid RFC 1123 hostname (LDH-compliant per RFC 1035 / RFC 1123).
* <p>
* Inline hostname sanitization ensures all output conforms to RFC 1123 LDH rules:
* - Each label 1-63 characters, total ≤253 characters
* - Labels contain only letters, digits, hyphens
* - Labels cannot start/end with hyphens
* - Falls back to "example"/"example.com" (RFC 6761) if unsanitizable
* <p>
* See RFC 3490 (IDNA), RFC 3491 (Punycode), RFC 1123, and RFC 6761.
*
* @since 1.0.0
*/
public class FakerIDN {
private static final int MAX_LABEL_LENGTH = 63;
private static final int MAX_HOSTNAME_LENGTH = 253;
/**
* {@link IDN#toASCII(String)} is too picky for our needs. It was throwing exceptions for fa.yml and
* he.yml as they are Bidi languages and something was causing them to die. This is kind of a brute force
* fix, but it appears to fix the issue.
* Converts a string to an ASCII hostname, applying Punycode (IDN) encoding where needed.
* Never throws; returns valid RFC 1123 hostname or "example" fallback.
*
* @param input the domain name to convert
* @return valid ASCII hostname (LDH-compliant per RFC 1123, never null or empty)
*/
public static String toASCII(String in) {
public static String toASCII(@Nullable String input) {
if (input == null || input.isEmpty()) {
return "example";
}

@Nullable String asciiResult = tryFullConversion(input);
if (asciiResult == null) {
asciiResult = tryCharacterByCharacter(input);
}

if (asciiResult == null || asciiResult.isEmpty()) {
asciiResult = "example";
}

// Sanitize to RFC 1123: lowercase, [a-z0-9-] only, no leading/trailing hyphens
return sanitizeHostname(asciiResult);
}

private static String sanitizeHostname(String input) {
String[] labels = input.split("\\.", -1);
StringBuilder result = new StringBuilder();

for (int i = 0; i < labels.length; i++) {
if (i > 0) result.append(".");
result.append(sanitizeLabel(labels[i]));
}

String hostname = result.toString();
if (hostname.length() > MAX_HOSTNAME_LENGTH) {
hostname = hostname.substring(0, MAX_HOSTNAME_LENGTH).replaceAll("\\.$", "");
}
return hostname.isEmpty() ? "example.com" : hostname;
}

private static String sanitizeLabel(String input) {
String result = input.toLowerCase(Locale.ROOT);
StringBuilder sanitized = new StringBuilder();
for (char c : result.toCharArray()) {
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
sanitized.append(c);
}
}
result = sanitized.toString();
// Preserve xn-- (Punycode prefix) but collapse other consecutive hyphens
if (!result.startsWith("xn--")) {
while (result.contains("--")) result = result.replace("--", "-");
}
result = result.replaceAll("^-+|-+$", "");
if (result.length() > MAX_LABEL_LENGTH) {
result = result.substring(0, MAX_LABEL_LENGTH).replaceAll("-+$", "");
}
return result.isEmpty() ? "example" : result;
}

@Nullable
private static String tryFullConversion(String input) {
try {
return IDN.toASCII(in);
return IDN.toASCII(input);
} catch (IllegalArgumentException ignore) {
// let's continue with the character by character encoding hack.
return null;
}
final StringBuilder asciiResult = new StringBuilder();
for (int i = 0; i < in.length(); i++) {
}

@Nullable
private static String tryCharacterByCharacter(String input) {
final StringBuilder result = new StringBuilder(input.length());
for (int i = 0; i < input.length(); i++) {
try {
asciiResult.append(IDN.toASCII(in.substring(i, i + 1)));
result.append(IDN.toASCII(input.substring(i, i + 1)));
} catch (IllegalArgumentException ignored) {
// Skip characters that cannot be converted
}
}
if (asciiResult.isEmpty()) {
throw new RuntimeException("Unable to convert \"%s\" to ASCII".formatted(in));
}
return asciiResult.toString();
return result.length() > 0 ? result.toString() : null;
}
}
140 changes: 140 additions & 0 deletions src/main/java/net/datafaker/internal/helper/HostnameHelper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package net.datafaker.internal.helper;

import java.net.IDN;
import java.util.Locale;

/**
* Helper for creating valid hostnames according to RFC 1123 (Letter-Digit-Hyphen rules).
* <p>
* This class provides methods to sanitize strings into valid DNS hostnames that conform to:
* - RFC 1035: Original DNS specification
* - RFC 1123: Relaxed hostname rules (allows digits at start of label)
* <p>
* Hostname rules per RFC 1123:
* - Each label (segment between dots) is 1-63 characters
* - Total hostname including dots must be ≤253 characters
* - Labels contain only letters, digits, and hyphens (LDH)
* - Labels cannot start or end with hyphens
* - Labels cannot be purely numeric
*
* @since 3.0.0
*/
public class HostnameHelper {
private static final int MAX_LABEL_LENGTH = 63;
private static final int MAX_HOSTNAME_LENGTH = 253;

/**
* Converts a string to a valid ASCII hostname label, sanitizing per RFC 1123 LDH rules.
* <p>
* Process:
* 1. Apply IDN.toASCII() for non-ASCII input (converts to Punycode if needed)
* 2. Convert to lowercase
* 3. Keep only [a-z0-9-]
* 4. Collapse consecutive hyphens (except preserve "xn--" Punycode prefix)
* 5. Strip leading/trailing hyphens
* 6. Enforce length ≤ 63 characters
* 7. Fall back to "example" if result is empty
* <p>
* The fallback value "example" is an IETF-reserved special-use domain name (RFC 6761)
* intended for documentation and examples.
*
* @param input the string to sanitize
* @param locale the locale for lowercase conversion (e.g., Locale.ROOT)
* @return a valid ASCII hostname label (never null or empty)
*/
public static String toAsciiHostnameLabel(String input, Locale locale) {
if (input == null || input.isEmpty()) {
return "example";
}

// 1. Try IDN conversion for Unicode input (converts to Punycode if needed)
String result = input;
try {
result = IDN.toASCII(input);
} catch (IllegalArgumentException ignore) {
// Not a valid IDN label; proceed with raw input
}

// 2. Lowercase the input
result = result.toLowerCase(locale);

// 3. Keep only [a-z0-9-]
StringBuilder sanitized = new StringBuilder();
for (char c : result.toCharArray()) {
if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
sanitized.append(c);
}
}

result = sanitized.toString();

// 4. Collapse consecutive hyphens (preserve "xn--" Punycode prefix)
if (!result.startsWith("xn--")) {
while (result.contains("--")) {
result = result.replace("--", "-");
}
}

// 5. Strip leading/trailing hyphens
result = result.replaceAll("^-+|-+$", "");

// 6. Enforce max label length
if (result.length() > MAX_LABEL_LENGTH) {
result = result.substring(0, MAX_LABEL_LENGTH);
result = result.replaceAll("-+$", "");
}

// 7. Return valid result or fallback
if (result.isEmpty()) {
return "example";
}
return result;
}

/**
* Sanitizes each label of a hostname independently and recombines.
* <p>
* If input contains dots, each segment is sanitized separately, then recombined.
* This is useful for domain names where each label has independent rules.
* <p>
* Fallback behavior:
* - Null or empty input returns "example.com"
* - Result that becomes empty after sanitization returns "example.com"
* <p>
* The fallback values are IETF-reserved special-use domain names (RFC 6761)
* intended for documentation and examples.
*
* @param input the hostname/domain to sanitize (may contain dots)
* @param locale the locale for lowercase conversion
* @return a valid ASCII hostname (never null or empty)
*/
public static String toAsciiHostname(String input, Locale locale) {
if (input == null || input.isEmpty()) {
return "example.com";
}

String[] labels = input.split("\\.", -1);
StringBuilder result = new StringBuilder();

for (int i = 0; i < labels.length; i++) {
if (i > 0) {
result.append(".");
}
result.append(toAsciiHostnameLabel(labels[i], locale));
}

String hostname = result.toString();

// Enforce total length
if (hostname.length() > MAX_HOSTNAME_LENGTH) {
// Truncate from the end, preserving the TLD
hostname = hostname.substring(0, MAX_HOSTNAME_LENGTH);
hostname = hostname.replaceAll("\\.$", "");
}

if (hostname.isEmpty()) {
return "example.com";
}
return hostname;
}
}
48 changes: 34 additions & 14 deletions src/main/java/net/datafaker/providers/base/Company.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.datafaker.providers.base;

import net.datafaker.internal.helper.FakerIDN;
import net.datafaker.internal.helper.HostnameHelper;
import net.datafaker.internal.helper.LazyEvaluated;

import java.util.Collection;
Expand Down Expand Up @@ -68,27 +69,46 @@ public String logo() {
return "https://pigment.github.io/fake-logos/logos/medium/color/" + number + ".png";
}

/**
* Returns a domain name based on the company name.
* <p>
* The domain name is created by sanitizing the company name according to RFC 1123
* (Letter-Digit-Hyphen rules). The result is always a valid ASCII hostname.
*
* @return a valid ASCII domain name
* @since 0.8.0
*/
public String domainName() {
String companyName = name();
return HostnameHelper.toAsciiHostnameLabel(companyName, faker.getContext().getLocale());
}

/**
* Returns a web URL for the company.
* <p>
* The domain name is created by sanitizing the company name according to RFC 1123
* (Letter-Digit-Hyphen rules), and the full URL is constructed with a domain suffix.
*
* @return a valid web URL
* @since 0.8.0
*/
public String url() {
return "www."
+ FakerIDN.toASCII(domainName()) + "."
+ domainSuffix();
}

private String domainName() {
final char[] res = name().toLowerCase(faker.getContext().getLocale()).toCharArray();
int offset = 0;
for (int i = 0; i < res.length; i++) {
final char c = res[i];
switch (c) {
case '.', ',', '\'', ' ', ']', '&' -> offset++;
default -> res[i - offset] = res[i];
}
}
return String.valueOf(res, 0, res.length - offset);
}

/**
* Returns a domain suffix (TLD).
* <p>
* The suffix is sanitized according to RFC 1123 to ensure it is ASCII-compatible.
* For non-ASCII locales, the suffix is converted to ASCII via the hostname sanitizer.
*
* @return a domain suffix (ASCII)
*/
private String domainSuffix() {
return resolve("internet.domain_suffix");
String suffix = resolve("internet.domain_suffix");
return HostnameHelper.toAsciiHostname(suffix, faker.getContext().getLocale());
}
Comment thread
kingthorin marked this conversation as resolved.

private String joinSampleOfEachList(List<List<String>> listOfLists) {
Expand Down
Loading