diff --git a/build.gradle.kts b/build.gradle.kts index b53fb0c7..2bc2fa2c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -271,7 +271,6 @@ dependencies { implementation(enforcedPlatform(libs.cel)) implementation(libs.cel.core) implementation(libs.guava) - implementation(libs.ipaddress) buf("build.buf:buf:${libs.versions.buf.get()}:${osdetector.classifier}@exe") diff --git a/conformance/expected-failures.yaml b/conformance/expected-failures.yaml index 32e5f20b..5d92bdca 100644 --- a/conformance/expected-failures.yaml +++ b/conformance/expected-failures.yaml @@ -107,94 +107,6 @@ custom_constraints: #ERROR: :1:1: expression of type 'int' cannot be range of a comprehension (must be list, map, or dynamic) # | this.all(e, e == 1) # | ^ -library/is_host_and_port: - - port_required/false/invalid/ipv6_zone-id_too_short - # input: [type.googleapis.com/buf.validate.conformance.cases.IsHostAndPort]:{val:"[::1%]"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_host_and_port" - # got: valid - - port_required/false/invalid/port_number_sign - # input: [type.googleapis.com/buf.validate.conformance.cases.IsHostAndPort]:{val:"example.com:+0"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_host_and_port" - # got: valid - - port_required/false/valid/ipv6_embedded_ipv4 - # input: [type.googleapis.com/buf.validate.conformance.cases.IsHostAndPort]:{val:"[0:0:0:0:0:ffff:192.1.56.10]"} - # want: valid - # got: validation error (1 violation) - # 1. constraint_id: "library.is_host_and_port" - # message: "" - - port_required/false/valid/ipv6_with_zone-id - # input: [type.googleapis.com/buf.validate.conformance.cases.IsHostAndPort]:{val:"[::1%foo]"} - # want: valid - # got: validation error (1 violation) - # 1. constraint_id: "library.is_host_and_port" - # message: "" - - port_required/false/valid/ipv6_zone-id_any_non_null_character - # input: [type.googleapis.com/buf.validate.conformance.cases.IsHostAndPort]:{val:"[::1%% :x\x1f]"} - # want: valid - # got: validation error (1 violation) - # 1. constraint_id: "library.is_host_and_port" - # message: "" - - port_required/true/invalid/port_number_sign - # input: [type.googleapis.com/buf.validate.conformance.cases.IsHostAndPort]:{val:"example.com:+0" port_required:true} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_host_and_port" - # got: valid -library/is_ip: - - version/omitted/invalid/ipv6_zone-id - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIp]:{val:"::1%"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip" - # got: valid - - version/omitted/valid/ipv6_zone-id - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIp]:{val:"::1%foo"} - # want: valid - # got: validation error (1 violation) - # 1. constraint_id: "library.is_ip" - # message: "" - - version/omitted/valid/ipv6_zone-id_any_non_null_character - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIp]:{val:"::1%% :x\x1f"} - # want: valid - # got: validation error (1 violation) - # 1. constraint_id: "library.is_ip" - # message: "" -library/is_ip_prefix: - - version/omitted/strict/omitted/invalid/ipv4_bad_leading_zero_in_prefix-length - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:"192.168.1.0/024"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid - - version/omitted/strict/omitted/invalid/ipv4_prefix_leading_space - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:" 127.0.0.1/16"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid - - version/omitted/strict/omitted/invalid/ipv4_prefix_trailing_space - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:"127.0.0.1/16 "} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid - - version/omitted/strict/omitted/invalid/ipv6_bad_leading_zero_in_prefix-length - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:"2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFFF/024"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid - - version/omitted/strict/omitted/invalid/ipv6_prefix_leading_space - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:" ::1/64"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid - - version/omitted/strict/omitted/invalid/ipv6_prefix_trailing_space - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:"::1/64 "} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid - - version/omitted/strict/omitted/invalid/ipv6_zone-id/a - # input: [type.googleapis.com/buf.validate.conformance.cases.IsIpPrefix]:{val:"::1%en1/64"} - # want: validation error (1 violation) - # 1. constraint_id: "library.is_ip_prefix" - # got: valid library/is_uri: - invalid/host/c # input: [type.googleapis.com/buf.validate.conformance.cases.IsUri]:{val:"https://foo@你好.com"} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 382e507b..fe4bac59 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,6 @@ assertj = "3.27.3" buf = "1.50.1" cel = "0.5.1" -ipaddress = "5.5.1" junit = "5.12.1" maven-publish = "0.31.0" # When updating, make sure to update versions in the following files to match and regenerate code with 'make generate'. @@ -19,7 +18,6 @@ cel = { module = "org.projectnessie.cel:cel-bom", version.ref = "cel" } cel-core = { module = "org.projectnessie.cel:cel-core" } errorprone = { module = "com.google.errorprone:error_prone_core", version = "2.37.0" } guava = { module = "com.google.guava:guava", version = "33.4.0-jre" } -ipaddress = { module = "com.github.seancfoley:ipaddress", version.ref = "ipaddress" } junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" } maven-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "maven-publish" } nullaway = { module = "com.uber.nullaway:nullaway", version = "0.12.4" } diff --git a/src/main/java/build/buf/protovalidate/CustomOverload.java b/src/main/java/build/buf/protovalidate/CustomOverload.java index 236df97c..889b0f78 100644 --- a/src/main/java/build/buf/protovalidate/CustomOverload.java +++ b/src/main/java/build/buf/protovalidate/CustomOverload.java @@ -14,18 +14,11 @@ package build.buf.protovalidate; -import com.google.common.base.Ascii; -import com.google.common.base.Splitter; -import com.google.common.net.InetAddresses; import com.google.common.primitives.Bytes; -import inet.ipaddr.IPAddress; -import inet.ipaddr.IPAddressString; -import java.net.Inet4Address; -import java.net.Inet6Address; -import java.net.InetAddress; import java.net.URI; import java.net.URISyntaxException; import java.util.HashSet; +import java.util.Locale; import java.util.Set; import java.util.regex.Pattern; import org.projectnessie.cel.common.types.BoolT; @@ -74,15 +67,15 @@ static Overload[] create() { startsWith(), endsWith(), contains(), - isHostname(), - isEmail(), - isIp(), - isIpPrefix(), - isUri(), - isUriRef(), + celIsHostname(), + celIsEmail(), + celIsIp(), + celIsIpPrefix(), + celIsUri(), + celIsUriRef(), isNan(), isInf(), - isHostAndPort(), + celIsHostAndPort(), }; } @@ -226,7 +219,7 @@ private static Overload contains() { * * @return The {@link Overload} instance for the "isHostname" operation. */ - private static Overload isHostname() { + private static Overload celIsHostname() { return Overload.unary( OVERLOAD_IS_HOSTNAME, value -> { @@ -234,10 +227,7 @@ private static Overload isHostname() { return Err.noSuchOverload(value, OVERLOAD_IS_HOSTNAME, null); } String host = (String) value.value(); - if (host.isEmpty()) { - return BoolT.False; - } - return Types.boolOf(validateHostname(host)); + return Types.boolOf(isHostname(host)); }); } @@ -246,7 +236,7 @@ private static Overload isHostname() { * * @return The {@link Overload} instance for the "isEmail" operation. */ - private static Overload isEmail() { + private static Overload celIsEmail() { return Overload.unary( OVERLOAD_IS_EMAIL, value -> { @@ -254,10 +244,7 @@ private static Overload isEmail() { return Err.noSuchOverload(value, OVERLOAD_IS_EMAIL, null); } String addr = (String) value.value(); - if (addr.isEmpty()) { - return BoolT.False; - } - return Types.boolOf(validateEmail(addr)); + return Types.boolOf(isEmail(addr)); }); } @@ -266,7 +253,7 @@ private static Overload isEmail() { * * @return The {@link Overload} instance for the "isIp" operation. */ - private static Overload isIp() { + private static Overload celIsIp() { return Overload.overload( OVERLOAD_IS_IP, null, @@ -275,20 +262,14 @@ private static Overload isIp() { return Err.noSuchOverload(value, OVERLOAD_IS_IP, null); } String addr = (String) value.value(); - if (addr.isEmpty()) { - return BoolT.False; - } - return Types.boolOf(validateIP(addr, 0L)); + return Types.boolOf(isIP(addr, 0L)); }, (lhs, rhs) -> { if (lhs.type().typeEnum() != TypeEnum.String || rhs.type().typeEnum() != TypeEnum.Int) { return Err.noSuchOverload(lhs, OVERLOAD_IS_IP, rhs); } String address = (String) lhs.value(); - if (address.isEmpty()) { - return BoolT.False; - } - return Types.boolOf(validateIP(address, rhs.intValue())); + return Types.boolOf(isIP(address, rhs.intValue())); }, null); } @@ -298,7 +279,7 @@ private static Overload isIp() { * * @return The {@link Overload} instance for the "isIpPrefix" operation. */ - private static Overload isIpPrefix() { + private static Overload celIsIpPrefix() { return Overload.overload( OVERLOAD_IS_IP_PREFIX, null, @@ -308,10 +289,7 @@ private static Overload isIpPrefix() { return Err.noSuchOverload(value, OVERLOAD_IS_IP_PREFIX, null); } String prefix = (String) value.value(); - if (prefix.isEmpty()) { - return BoolT.False; - } - return Types.boolOf(validateIPPrefix(prefix, 0L, false)); + return Types.boolOf(isIPPrefix(prefix, 0L, false)); }, (lhs, rhs) -> { if (lhs.type().typeEnum() != TypeEnum.String @@ -320,13 +298,10 @@ private static Overload isIpPrefix() { return Err.noSuchOverload(lhs, OVERLOAD_IS_IP_PREFIX, rhs); } String prefix = (String) lhs.value(); - if (prefix.isEmpty()) { - return BoolT.False; - } if (rhs.type().typeEnum() == TypeEnum.Int) { - return Types.boolOf(validateIPPrefix(prefix, rhs.intValue(), false)); + return Types.boolOf(isIPPrefix(prefix, rhs.intValue(), false)); } - return Types.boolOf(validateIPPrefix(prefix, 0L, rhs.booleanValue())); + return Types.boolOf(isIPPrefix(prefix, 0L, rhs.booleanValue())); }, (values) -> { if (values.length != 3 @@ -336,11 +311,7 @@ private static Overload isIpPrefix() { return Err.noSuchOverload(values[0], OVERLOAD_IS_IP_PREFIX, "", values); } String prefix = (String) values[0].value(); - if (prefix.isEmpty()) { - return BoolT.False; - } - return Types.boolOf( - validateIPPrefix(prefix, values[1].intValue(), values[2].booleanValue())); + return Types.boolOf(isIPPrefix(prefix, values[1].intValue(), values[2].booleanValue())); }); } @@ -349,7 +320,7 @@ private static Overload isIpPrefix() { * * @return The {@link Overload} instance for the "isUri" operation. */ - private static Overload isUri() { + private static Overload celIsUri() { return Overload.unary( OVERLOAD_IS_URI, value -> { @@ -369,7 +340,7 @@ private static Overload isUri() { * * @return The {@link Overload} instance for the "isUriRef" operation. */ - private static Overload isUriRef() { + private static Overload celIsUriRef() { return Overload.unary( OVERLOAD_IS_URI_REF, value -> { @@ -432,7 +403,7 @@ private static Overload isInf() { null); } - private static Overload isHostAndPort() { + private static Overload celIsHostAndPort() { return Overload.overload( OVERLOAD_IS_HOST_AND_PORT, null, @@ -443,39 +414,74 @@ private static Overload isHostAndPort() { } String value = (String) lhs.value(); boolean portRequired = rhs.booleanValue(); - return Types.boolOf(hostAndPort(value, portRequired)); + return Types.boolOf(isHostAndPort(value, portRequired)); }, null); } - private static boolean hostAndPort(String value, boolean portRequired) { - if (value.isEmpty()) { + /** + * Returns true if the string is a valid host/port pair, for example "example.com:8080". + * + *

If the argument portRequired is true, the port is required. If the argument is false, the + * port is optional. + * + *

The host can be one of: + * + *

+ * + *

The port is separated by a colon. It must be non-empty, with a decimal number in the range + * of 0-65535, inclusive. + */ + private static boolean isHostAndPort(String str, boolean portRequired) { + if (str.isEmpty()) { return false; } - int splitIdx = value.lastIndexOf(':'); - if (value.charAt(0) == '[') { // ipv6 - int end = value.indexOf(']'); - if (end + 1 == value.length()) { // no port - return !portRequired && validateIP(value.substring(1, end), 6); - } - if (end + 1 == splitIdx) { // port - return validateIP(value.substring(1, end), 6) - && validatePort(value.substring(splitIdx + 1)); + + int splitIdx = str.lastIndexOf(':'); + + if (str.charAt(0) == '[') { + int end = str.lastIndexOf(']'); + + int endPlus = end + 1; + if (endPlus == str.length()) { // no port + return !portRequired && isIP(str.substring(1, end), 6); + } else if (endPlus == splitIdx) { // port + return isIP(str.substring(1, end), 6) && isPort(str.substring(splitIdx + 1)); } return false; // malformed } + if (splitIdx < 0) { - return !portRequired && (validateHostname(value) || validateIP(value, 4)); + return !portRequired && (isHostname(str) || isIP(str, 4)); } - String host = value.substring(0, splitIdx); - String port = value.substring(splitIdx + 1); - return (validateHostname(host) || validateIP(host, 4)) && validatePort(port); + + String host = str.substring(0, splitIdx); + String port = str.substring(splitIdx + 1); + + return ((isHostname(host) || isIP(host, 4)) && isPort(port)); } - private static boolean validatePort(String value) { + // Returns true if the string is a valid port for isHostAndPort. + private static boolean isPort(String str) { + if (str.isEmpty()) { + return false; + } + + for (int i = 0; i < str.length(); i++) { + char c = str.charAt(i); + if ('0' <= c && c <= '9') { + continue; + } + return false; + } + try { - int portNum = Integer.parseInt(value); - return portNum >= 0 && portNum <= 65535; + int val = Integer.parseInt(str); + return val <= 65535; } catch (NumberFormatException nfe) { return false; } @@ -518,7 +524,7 @@ private static Val uniqueList(Lister list) { } /** - * validateEmail returns true if addr is a valid email address. + * isEmail returns true if addr is a valid email address. * *

This regex conforms to the definition for a valid email address from the HTML standard. Note * that this standard willfully deviates from RFC 5322, which allows many unexpected forms of @@ -527,66 +533,86 @@ private static Val uniqueList(Lister list) { * @param addr The input string to validate as an email address. * @return {@code true} if the input string is a valid email address, {@code false} otherwise. */ - private static boolean validateEmail(String addr) { + private static boolean isEmail(String addr) { return EMAIL_REGEX.matcher(addr).matches(); } /** - * Validates if the input string is a valid hostname. + * Returns true if the string is a valid hostname, for example "foo.example.com". * - * @param host The input string to validate as a hostname. - * @return {@code true} if the input string is a valid hostname, {@code false} otherwise. + *

A valid hostname follows the rules below: + * + *

*/ - private static boolean validateHostname(String host) { - if (host.length() > 253) { + private static boolean isHostname(String val) { + if (val.length() > 253) { return false; } - String s = Ascii.toLowerCase(host.endsWith(".") ? host.substring(0, host.length() - 1) : host); - Iterable parts = Splitter.on('.').split(s); + + String str; + if (val.endsWith(".")) { + str = val.substring(0, val.length() - 1); + } else { + str = val; + } + boolean allDigits = false; + + String[] parts = str.toLowerCase(Locale.getDefault()).split("\\.", -1); + + // split hostname on '.' and validate each part for (String part : parts) { allDigits = true; - int l = part.length(); - if (l == 0 || l > 63 || part.charAt(0) == '-' || part.charAt(l - 1) == '-') { + + // if part is empty, longer than 63 chars, or starts/ends with '-', it is invalid + int len = part.length(); + if (len == 0 || len > 63 || part.startsWith("-") || part.endsWith("-")) { return false; } - for (int i = 0; i < l; i++) { - char ch = part.charAt(i); - if (!Ascii.isLowerCase(ch) && !isDigit(ch) && ch != '-') { + + // for each character in part + for (int i = 0; i < part.length(); i++) { + char c = part.charAt(i); + // if the character is not a-z, 0-9, or '-', it is invalid + if ((c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-') { return false; } - allDigits = allDigits && isDigit(ch); + + allDigits = allDigits && c >= '0' && c <= '9'; } } + // the last part cannot be all numbers return !allDigits; } - private static boolean isDigit(char c) { - return c >= '0' && c <= '9'; - } - /** - * Validates if the input string is a valid IP address. + * Returns true if the string is an IPv4 or IPv6 address, optionally limited to a specific + * version. + * + *

Version 0 means either 4 or 6. Passing a version other than 0, 4, or 6 always returns false. + * + *

IPv4 addresses are expected in the dotted decimal format, for example "192.168.5.21". IPv6 + * addresses are expected in their text representation, for example "::1", or + * "2001:0DB8:ABCD:0012::0". * - * @param addr The input string to validate as an IP address. - * @param ver The IP version to validate against (0 for any version, 4 for IPv4, 6 for IPv6). - * @return {@code true} if the input string is a valid IP address of the specified version, {@code - * false} otherwise. + *

Both formats are well-defined in the internet standard RFC 3986. Zone identifiers for IPv6 + * addresses (for example "fe80::a%en1") are supported. */ - private static boolean validateIP(String addr, long ver) { - InetAddress address; - try { - address = InetAddresses.forString(addr); - } catch (Exception e) { - return false; - } - if (ver == 0L) { - return true; + private static boolean isIP(String addr, long ver) { + if (ver == 6L) { + return new Ipv6(addr).address(); } else if (ver == 4L) { - return address instanceof Inet4Address; - } else if (ver == 6L) { - return address instanceof Inet6Address; + return new Ipv4(addr).address(); + } else if (ver == 0L) { + return new Ipv4(addr).address() || new Ipv6(addr).address(); } return false; } @@ -611,39 +637,31 @@ private static boolean validateURI(String val, boolean checkAbsolute) { } /** - * Validates if the input string is a valid IP prefix. + * Returns true if the string is a valid IP with prefix length, optionally limited to a specific + * version (v4 or v6), and optionally requiring the host portion to be all zeros. * - * @param prefix The input string to validate as an IP prefix. - * @param ver The IP version to validate against (0 for any version, 4 for IPv4, 6 for IPv6). - * @param strict If strict is true and host bits are set in the supplied address, then false is - * returned. - * @return {@code true} if the input string is a valid IP prefix of the specified version, {@code - * false} otherwise. + *

An address prefix divides an IP address into a network portion, and a host portion. The + * prefix length specifies how many bits the network portion has. For example, the IPv6 prefix + * "2001:db8:abcd:0012::0/64" designates the left-most 64 bits as the network prefix. The range of + * the network is 2**64 addresses, from 2001:db8:abcd:0012::0 to + * 2001:db8:abcd:0012:ffff:ffff:ffff:ffff. + * + *

An address prefix may include a specific host address, for example + * "2001:db8:abcd:0012::1f/64". With strict = true, this is not permitted. The host portion must + * be all zeros, as in "2001:db8:abcd:0012::0/64". + * + *

The same principle applies to IPv4 addresses. "192.168.1.0/24" designates the first 24 bits + * of the 32-bit IPv4 as the network prefix. */ - private static boolean validateIPPrefix(String prefix, long ver, boolean strict) { - IPAddressString str; - IPAddress addr; - try { - str = new IPAddressString(prefix); - addr = str.toAddress(); - } catch (Exception e) { - return false; - } - if (!addr.isPrefixed()) { - return false; - } - if (strict) { - IPAddress mask = addr.getNetworkMask().withoutPrefixLength(); - if (!addr.mask(mask).equals(str.getHostAddress())) { - return false; - } - } - if (ver == 0L) { - return true; - } else if (ver == 4L) { - return addr.isIPv4(); - } else if (ver == 6L) { - return addr.isIPv6(); + private static boolean isIPPrefix(String str, long version, boolean strict) { + if (version == 6L) { + Ipv6 ip = new Ipv6(str); + return ip.addressPrefix() && (!strict || ip.isPrefixOnly()); + } else if (version == 4L) { + Ipv4 ip = new Ipv4(str); + return ip.addressPrefix() && (!strict || ip.isPrefixOnly()); + } else if (version == 0L) { + return isIPPrefix(str, 6, strict) || isIPPrefix(str, 4, strict); } return false; } diff --git a/src/main/java/build/buf/protovalidate/Ipv4.java b/src/main/java/build/buf/protovalidate/Ipv4.java new file mode 100644 index 00000000..52f249a3 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/Ipv4.java @@ -0,0 +1,204 @@ +// Copyright 2023-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build.buf.protovalidate; + +import java.util.ArrayList; +import java.util.List; + +final class Ipv4 { + private String str; + private int index; + private List octets; + private int prefixLen; + + Ipv4(String str) { + this.str = str; + this.octets = new ArrayList(); + } + + /** + * Returns the 32-bit value of an address parsed through address() or addressPrefix(). + * + *

Returns -1 if no address was parsed successfully. + */ + int getBits() { + if (this.octets.size() != 4) { + return -1; + } + return (this.octets.get(0) << 24) + | (this.octets.get(1) << 16) + | (this.octets.get(2) << 8) + | this.octets.get(3); + } + + /** + * Returns true if all bits to the right of the prefix-length are all zeros. + * + *

Behavior is undefined if addressPrefix() has not been called before, or has returned false. + */ + boolean isPrefixOnly() { + int bits = this.getBits(); + + int mask = 0; + if (this.prefixLen == 32) { + mask = 0xffffffff; + } else { + mask = ~(0xffffffff >>> this.prefixLen); + } + + int masked = bits & mask; + + return bits == masked; + } + + // Parses an IPv4 Address in dotted decimal notation. + boolean address() { + return this.addressPart() && this.index == this.str.length(); + } + + // Parses an IPv4 Address prefix. + boolean addressPrefix() { + return this.addressPart() + && this.take('/') + && this.prefixLength() + && this.index == this.str.length(); + } + + // Stores value in `prefixLen` + private boolean prefixLength() { + int start = this.index; + + while (this.index < this.str.length() && this.digit()) { + if (this.index - start > 2) { + // max prefix-length is 32 bits, so anything more than 2 digits is invalid + return false; + } + } + + String str = this.str.substring(start, this.index); + if (str.isEmpty()) { + // too short + return false; + } + + if (str.length() > 1 && str.charAt(0) == '0') { + // bad leading 0 + return false; + } + + try { + int val = Integer.parseInt(str); + + if (val > 32) { + // max 32 bits + return false; + } + + this.prefixLen = val; + return true; + } catch (NumberFormatException nfe) { + return false; + } + } + + private boolean addressPart() { + int start = this.index; + + if (this.decOctet() + && this.take('.') + && this.decOctet() + && this.take('.') + && this.decOctet() + && this.take('.') + && this.decOctet()) { + return true; + } + + this.index = start; + + return false; + } + + private boolean decOctet() { + int start = this.index; + + while (this.index < this.str.length() && this.digit()) { + if (this.index - start > 3) { + // decimal octet can be three characters at most + return false; + } + } + + String str = this.str.substring(start, this.index); + if (str.isEmpty()) { + // too short + return false; + } + + if (str.length() > 1 && str.charAt(0) == '0') { + // bad leading 0 + return false; + } + + try { + int val = Integer.parseInt(str); + + if (val > 255) { + return false; + } + + this.octets.add((short) val); + + return true; + } catch (NumberFormatException nfe) { + // Error converting to number + return false; + } + } + + /** + * Reports whether the current position is a digit. + * + *

Method parses the rule: + * + *

DIGIT = %x30-39 ; 0-9
+   */
+  private boolean digit() {
+    char c = this.str.charAt(this.index);
+    if ('0' <= c && c <= '9') {
+      this.index++;
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Take the given char at the current index.
+   *
+   * 

If char is at the current index, increment the index. + */ + private boolean take(char c) { + if (this.index >= this.str.length()) { + return false; + } + + if (this.str.charAt(this.index) == c) { + this.index++; + return true; + } + + return false; + } +} diff --git a/src/main/java/build/buf/protovalidate/Ipv6.java b/src/main/java/build/buf/protovalidate/Ipv6.java new file mode 100644 index 00000000..aa178943 --- /dev/null +++ b/src/main/java/build/buf/protovalidate/Ipv6.java @@ -0,0 +1,345 @@ +// Copyright 2023-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package build.buf.protovalidate; + +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Nullable; + +final class Ipv6 { + private String str; + private int index; + // 16-bit pieces found + private List pieces; + // number of 16-bit pieces found when double colon was found + private int doubleColonAt; + private boolean doubleColonSeen; + // dotted notation for right-most 32 bits + private String dottedRaw; + // dotted notation successfully parsed as IPv4 + @Nullable private Ipv4 dottedAddr; + private boolean zoneIDFound; + // 0 - 128 + private int prefixLen; + + Ipv6(String str) { + this.str = str; + this.pieces = new ArrayList(); + this.doubleColonAt = -1; + this.dottedRaw = ""; + } + + /** + * Returns the 128-bit value of an address parsed through address() or addressPrefix() as a + * 2-element length array of 64-bit values. + * + *

Returns [0L, 0L] if no address was parsed successfully. + */ + private long[] getBits() { + List p16 = this.pieces; + + // handle dotted decimal, add to p16 + if (this.dottedAddr != null) { + // right-most 32 bits + long dotted32 = this.dottedAddr.getBits(); + // high 16 bits + p16.add((int) (dotted32 >> 16)); + // low 16 bits + p16.add((int) dotted32); + } + + // handle double colon, fill pieces with 0 + if (this.doubleColonSeen) { + while (p16.size() < 8) { + p16.add(this.doubleColonAt, 0x00000000); + } + } + + if (p16.size() != 8) { + return new long[] {0L, 0L}; + } + + return new long[] { + Long.valueOf(p16.get(0)) << 48 + | Long.valueOf(p16.get(1)) << 32 + | Long.valueOf(p16.get(2)) << 16 + | Long.valueOf(p16.get(3)), + Long.valueOf(p16.get(4)) << 48 + | Long.valueOf(p16.get(5)) << 32 + | Long.valueOf(p16.get(6)) << 16 + | Long.valueOf(p16.get(7)) + }; + } + + boolean isPrefixOnly() { + // For each 64-bit piece of the address, require that values to the right of the prefix are zero + long[] bits = this.getBits(); + for (int i = 0; i < bits.length; i++) { + long p64 = bits[i]; + long size = this.prefixLen - 64L * i; + + long mask = 0L; + if (size >= 64) { + mask = 0xFFFFFFFFFFFFFFFFL; + } else if (size < 0) { + mask = 0x0; + } else { + mask = ~(0xFFFFFFFFFFFFFFFFL >>> size); + } + long masked = p64 & mask; + if (p64 != masked) { + return false; + } + } + + return true; + } + + // Parses an IPv6 Address following RFC 4291, with optional zone id following RFC 4007. + boolean address() { + return this.addressPart() && this.index == this.str.length(); + } + + // Parse IPv6 Address Prefix following RFC 4291. Zone id is not permitted. + boolean addressPrefix() { + return this.addressPart() + && !this.zoneIDFound + && this.take('/') + && this.prefixLength() + && this.index == this.str.length(); + } + + // Stores value in `prefixLen` + private boolean prefixLength() { + int start = this.index; + + while (this.index < this.str.length() && this.digit()) { + if (this.index - start > 3) { + return false; + } + } + + String str = this.str.substring(start, this.index); + + if (str.isEmpty()) { + // too short + return false; + } + + if (str.length() > 1 && str.charAt(0) == '0') { + // bad leading 0 + return false; + } + + try { + int val = Integer.parseInt(str); + + if (val > 128) { + // max 128 bits + return false; + } + + this.prefixLen = val; + return true; + } catch (NumberFormatException nfe) { + // Error converting to number + return false; + } + } + + // Stores dotted notation for right-most 32 bits in `dottedRaw` / `dottedAddr` if found. + private boolean addressPart() { + while (this.index < this.str.length()) { + // dotted notation for right-most 32 bits, e.g. 0:0:0:0:0:ffff:192.1.56.10 + if ((this.doubleColonSeen || this.pieces.size() == 6) && this.dotted()) { + Ipv4 dotted = new Ipv4(this.dottedRaw); + if (dotted.address()) { + this.dottedAddr = dotted; + return true; + } + return false; + } + + if (this.h16()) { + continue; + } + + if (this.take(':')) { + if (this.take(':')) { + if (this.doubleColonSeen) { + return false; + } + + this.doubleColonSeen = true; + this.doubleColonAt = this.pieces.size(); + if (this.take(':')) { + return false; + } + } + continue; + } + + if (this.str.charAt(this.index) == '%' && !this.zoneID()) { + return false; + } + + break; + } + + return this.doubleColonSeen || this.pieces.size() == 8; + } + + /** + * There is no definition for the character set allowed in the zone identifier. RFC 4007 permits + * basically any non-null string. + * + *

RFC 6874: ZoneID = 1*( unreserved / pct-encoded )
+   */
+  private boolean zoneID() {
+    int start = this.index;
+
+    if (this.take('%')) {
+      if (this.str.length() - this.index > 0) {
+        // permit any non-null string
+        this.index = this.str.length();
+        this.zoneIDFound = true;
+
+        return true;
+      }
+    }
+
+    this.index = start;
+    this.zoneIDFound = false;
+
+    return false;
+  }
+
+  /**
+   * Determines whether string contains a dotted address.
+   *
+   * 

Method parses the rule: + * + *

1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT "." 1*3DIGIT
+   *
+   * 

Stores match in dottedRaw. + */ + private boolean dotted() { + int start = this.index; + + this.dottedRaw = ""; + + while (this.index < this.str.length() && (this.digit() || this.take('.'))) {} + + if (this.index - start >= 7) { + this.dottedRaw = this.str.substring(start, this.index); + + return true; + } + + this.index = start; + + return false; + } + + /** + * Determine whether string contains an h16. + * + *

Method parses the rule: + * + *

h16 = 1*4HEXDIG
+   *
+   * 

Stores 16-bit value in pieces. + */ + private boolean h16() { + int start = this.index; + + while (this.index < this.str.length() && this.hexDig()) {} + + String str = this.str.substring(start, this.index); + + if (str.isEmpty()) { + // too short + return false; + } + + if (str.length() > 4) { + // too long + return false; + } + + try { + int val = Integer.parseInt(str, 16); + + this.pieces.add(val); + return true; + } catch (NumberFormatException nfe) { + // Error converting to number + return false; + } + } + + /** + * Reports whether the current position is a hex digit. + * + *

Method parses the rule: + * + *

HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F"
+   */
+  private boolean hexDig() {
+    char c = this.str.charAt(this.index);
+
+    if (('0' <= c && c <= '9') || ('a' <= c && c <= 'f') || ('A' <= c && c <= 'F')) {
+      this.index++;
+
+      return true;
+    }
+
+    return false;
+  }
+
+  /**
+   * Reports whether the current position is a digit.
+   *
+   * 

Method parses the rule: + * + *

DIGIT = %x30-39 ; 0-9
+   */
+  private boolean digit() {
+    char c = this.str.charAt(this.index);
+    if ('0' <= c && c <= '9') {
+      this.index++;
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Take the given char at the current index.
+   *
+   * 

If char is at the current index, increment the index. + */ + private boolean take(char c) { + if (this.index >= this.str.length()) { + return false; + } + + if (this.str.charAt(this.index) == c) { + this.index++; + return true; + } + + return false; + } +}