From 58e1e7ba79eb69242e05c4369dbbaccb75612abb Mon Sep 17 00:00:00 2001 From: Nikita Vasilev Date: Mon, 8 Dec 2025 20:08:21 +0400 Subject: [PATCH] feat: add ip address validation rule --- .swiftlint.yml | 3 + README.md | 1 + .../Rules/IPAddressValidationRule.swift | 125 +++++++ .../ValidatorCore/Validator.docc/Overview.md | 1 + .../Rules/IPAddressValidationRuleTests.swift | 316 ++++++++++++++++++ 5 files changed, 446 insertions(+) create mode 100644 Sources/ValidatorCore/Classes/Rules/IPAddressValidationRule.swift create mode 100644 Tests/ValidatorCoreTests/UnitTests/Rules/IPAddressValidationRuleTests.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 3e3c3ec..548b390 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -97,6 +97,9 @@ identifier_name: excluded: - id - URL + - ip + - v6 + - v4 analyzer_rules: - unused_import diff --git a/README.md b/README.md index df2403f..e14ac99 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,7 @@ struct RegistrationView: View { | `EqualityValidationRule`| Validates that the input is equal to a given reference value | `EqualityValidationRule(compareTo: password, error: "Passwords do not match")` | `ComparisonValidationRule` | Validates that input against a comparison constraint | `ComparisonValidationRule(greaterThan: 0, error: "Must be greater than 0")` | `IBANValidationRule` | Validates that a string is a valid IBAN (International Bank Account Number) | `IBANValidationRule(error: "Invalid IBAN")` +| `IPAddressValidationRule` | Validates that a string is a valid IPv4 or IPv6 address | `IPAddressValidationRule(version: .v4, error: ValidationError("Invalid IPv4"))` ## Custom Validators diff --git a/Sources/ValidatorCore/Classes/Rules/IPAddressValidationRule.swift b/Sources/ValidatorCore/Classes/Rules/IPAddressValidationRule.swift new file mode 100644 index 0000000..ae7b8e6 --- /dev/null +++ b/Sources/ValidatorCore/Classes/Rules/IPAddressValidationRule.swift @@ -0,0 +1,125 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +import Foundation + +/// A validation rule for checking whether the input string is a valid IPv4 or IPv6 address. +/// +/// This rule supports: +/// - Standard IPv4 addresses (e.g., `192.168.1.1`) +/// - Standard and compressed IPv6 addresses (e.g., `2001:db8::1`) +/// +/// The rule fails validation if: +/// - The input is empty +/// - The input contains spaces, tabs, or newlines +/// - The format does not match the expected IPv4/IPv6 specification +/// +/// Example: +/// ```swift +/// let rule = IPAddressValidationRule(version: .v4, error: ValidationError("Invalid IPv4")) +/// rule.validate(input: "192.168.1.1") // true +/// ``` +public struct IPAddressValidationRule: IValidationRule { + // MARK: Types + + public typealias Input = String + + /// Specifies which IP protocol version the rule should validate. + public enum Version { + case v4 + case v6 + } + + // MARK: Properties + + /// The expected IP standard (IPv4 or IPv6). + public let version: Version + + /// The validation error returned when validation fails. + public let error: IValidationError + + // MARK: Initialization + + /// Creates an IP address validation rule. + /// + /// - Parameters: + /// - version: The expected IP protocol version (.v4 or .v6). + /// - error: The validation error returned if validation fails. + public init(version: Version, error: IValidationError) { + self.version = version + self.error = error + } + + // MARK: IValidationRule + + public func validate(input: String) -> Bool { + if input.isEmpty { return false } + + if input.contains(" ") || input.contains("\t") || input.contains("\n") { + return false + } + + switch version { + case .v4: + return validateIPv4(input) + case .v6: + return validateIPv6(input) + } + } + + // MARK: Private + + /// Validates an IPv4 address using a strict regex pattern that ensures each octet is between 0–255. + /// + /// - Parameter input: The IPv4 string. + /// + /// - Returns: `true` if valid, otherwise `false`. + private func validateIPv4(_ input: String) -> Bool { + let pattern = #"^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\."# + + #"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\."# + + #"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\."# + + #"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"# + + if let regex = try? NSRegularExpression(pattern: pattern) { + let range = NSRange(location: 0, length: input.utf16.count) + return regex.firstMatch(in: input, options: [], range: range) != nil + } + + return false + } + + /// Validates an IPv6 address using system-level parsing (`inet_pton`) and basic structural checks. + /// + /// Supports: + /// - Full IPv6 addresses + /// - Compressed forms (e.g., `::1`) + /// - IPv4-mapped IPv6 forms (e.g., `::ffff:192.168.0.1`) + /// + /// - Parameter input: The IPv6 string. + /// + /// - Returns: `true` if valid, otherwise `false`. + private func validateIPv6(_ input: String) -> Bool { + let components = input.components(separatedBy: ":") + + for component in components where !component.isEmpty { + if component.contains(".") { + continue + } + + if component.count > 4 { + return false + } + + if !component.allSatisfy(\.isHexDigit) { + return false + } + } + + var addr = sockaddr_in6() + return input.withCString { cString in + inet_pton(AF_INET6, cString, &addr.sin6_addr) == 1 + } + } +} diff --git a/Sources/ValidatorCore/Validator.docc/Overview.md b/Sources/ValidatorCore/Validator.docc/Overview.md index 78cce35..588b742 100644 --- a/Sources/ValidatorCore/Validator.docc/Overview.md +++ b/Sources/ValidatorCore/Validator.docc/Overview.md @@ -38,6 +38,7 @@ ValidatorCore contains all core validation rules, utilities, and mechanisms for - ``EqualityValidationRule`` - ``ComparisonValidationRule`` - ``IBANValidationRule`` +- ``IPAddressValidationRule`` ### Articles diff --git a/Tests/ValidatorCoreTests/UnitTests/Rules/IPAddressValidationRuleTests.swift b/Tests/ValidatorCoreTests/UnitTests/Rules/IPAddressValidationRuleTests.swift new file mode 100644 index 0000000..e803495 --- /dev/null +++ b/Tests/ValidatorCoreTests/UnitTests/Rules/IPAddressValidationRuleTests.swift @@ -0,0 +1,316 @@ +// +// Validator +// Copyright © 2025 Space Code. All rights reserved. +// + +@testable import ValidatorCore +import XCTest + +final class IPAddressValidationRuleTests: XCTestCase { + // MARK: Tests + + func test_thatIPv4ValidationRuleValidatesInput_whenInputIsValid() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let validIPs = [ + "192.168.0.1", + "0.0.0.0", + "255.255.255.255", + "127.0.0.1", + "10.10.10.10", + ] + + // then + for ip in validIPs { + let result = rule.validate(input: ip) + XCTAssertTrue(result, "Expected valid for: \(ip)") + } + } + + func test_thatIPv4ValidationRuleInvalidatesInput_whenInputIsInvalid() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let invalidIPs = [ + "256.256.256.256", + "192.168.0", + "192.168.0.1.1", + "abc.def.ghi.jkl", + "999.10.10.10", + "", + " ", + ] + + // then + for ip in invalidIPs { + let result = rule.validate(input: ip) + XCTAssertFalse(result, "Expected invalid for: \(ip)") + } + } + + func test_thatIPv6ValidationRuleValidatesInput_whenInputIsValid() { + // given + let rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let validIPs = [ + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "abcd:1234:5678:9abc:def0:0000:0000:1111", + "FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF", + ] + + // then + for ip in validIPs { + let result = rule.validate(input: ip) + XCTAssertTrue(result, "Expected valid for: \(ip)") + } + } + + func test_thatIPv6ValidationRuleInvalidatesInput_whenInputIsInvalid() { + // given + let rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let invalidIPs = [ + "2001:db8:::1", + "12345::", + "GGGG:0000:0000:0000:0000:0000:0000:0000", + "2001:db8:85a3", + "", + " ", + ] + + // then + for ip in invalidIPs { + let result = rule.validate(input: ip) + XCTAssertFalse(result, "Expected invalid for: \(ip)") + } + } + + func test_thatIPv4ValidationRuleInvalidatesInput_whenOctetsAreOutOfRange() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let invalidIPs = [ + "256.0.0.1", + "0.256.0.1", + "0.0.256.1", + "0.0.0.256", + "-1.0.0.0", + "192.-168.0.1", + "300.300.300.300", + ] + + // then + for ip in invalidIPs { + let result = rule.validate(input: ip) + XCTAssertFalse(result, "Expected invalid for: \(ip)") + } + } + + func test_thatIPv4ValidationRuleInvalidatesInput_whenFormatIsIncorrect() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let invalidIPs = [ + "192.168.0", + "192.168", + "192", + "192.168.0.1.5", + "192.168.0.1.5.6", + "192..168.0.1", + ".192.168.0.1", + "192.168.0.1.", + "192 .168.0.1", + "192.168. 0.1", + "192,168,0,1", + "192:168:0:1", + ] + + // then + for ip in invalidIPs { + let result = rule.validate(input: ip) + XCTAssertFalse(result, "Expected invalid for: \(ip)") + } + } + + func test_thatIPv4ValidationRuleInvalidatesInput_whenContainsNonNumericCharacters() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let invalidIPs = [ + "192.168.O.1", + "192.168.0.l", + "abc.def.ghi.jkl", + "192.168.0.1a", + "a192.168.0.1", + "192.168.0x0.1", + "192.168.0.1\n", + "192.168.0.1\t", + ] + + // then + for ip in invalidIPs { + let result = rule.validate(input: ip) + XCTAssertFalse(result, "Expected invalid for: \(ip)") + } + } + + func test_thatIPv4ValidationRuleHandlesLeadingZeros() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let ipsWithLeadingZeros = [ + "192.168.001.001", + "010.010.010.010", + "001.002.003.004", + ] + + // then + for ip in ipsWithLeadingZeros { + let result = rule.validate(input: ip) + XCTAssertTrue(result) + } + } + + func test_thatIPv6ValidationRuleValidatesInput_whenUsingShorthandNotation() { + // given + let rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let validIPs = [ + "::", + "::1", + "2001:db8::1", + "2001:db8::8a2e:370:7334", + "::ffff:192.0.2.1", + "fe80::", + "ff02::1", + ] + + // then + for ip in validIPs { + let result = rule.validate(input: ip) + XCTAssertTrue(result, "Expected valid for: \(ip)") + } + } + + func test_thatIPv6ValidationRuleInvalidatesInput_whenHexGroupsAreInvalid() { + // given + let rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let invalidIPs = [ + "GGGGG::1", + "12345::1", + "2001:0db8:85a3::8a2e:0370:7334:extra", + ":::", + "2001::db8::1", + ":2001:db8::1", + "2001:db8::1:", + "02001:db8::1", + ] + + // then + for ip in invalidIPs { + let result = rule.validate(input: ip) + XCTAssertFalse(result, "Expected invalid for: \(ip)") + } + } + + func test_thatIPv6ValidationRuleInvalidatesInput_whenMixedCase() { + // given + let rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let validIPs = [ + "2001:0DB8:85A3:0000:0000:8A2E:0370:7334", + "2001:0db8:85a3:0000:0000:8a2e:0370:7334", + "2001:0Db8:85A3:0000:0000:8a2E:0370:7334", + ] + + // then + for ip in validIPs { + let result = rule.validate(input: ip) + XCTAssertTrue(result, "Expected valid for: \(ip)") + } + } + + func test_thatValidationRuleInvalidatesInput_whenContainsWhitespace() { + // given + let ipv4Rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + let ipv6Rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let invalidIPv4s = [ + " 192.168.0.1", + "192.168.0.1 ", + "192 .168.0.1", + "\t192.168.0.1", + "192.168.0.1\n", + ] + + let invalidIPv6s = [ + " 2001:db8::1", + "2001:db8::1 ", + "2001: db8::1", + "\t2001:db8::1", + ] + + // then + for ip in invalidIPv4s { + XCTAssertFalse(ipv4Rule.validate(input: ip), "Expected invalid for: '\(ip)'") + } + + for ip in invalidIPv6s { + XCTAssertFalse(ipv6Rule.validate(input: ip), "Expected invalid for: '\(ip)'") + } + } + + func test_thatIPv4ValidationRuleValidatesInput_whenUsingBoundaryValues() { + // given + let rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + + let boundaryIPs = [ + "0.0.0.0", + "255.255.255.255", + "127.0.0.1", + "255.0.0.0", + "0.255.0.0", + "0.0.255.0", + "0.0.0.255", + ] + + // then + for ip in boundaryIPs { + let result = rule.validate(input: ip) + XCTAssertTrue(result, "Expected valid for boundary value: \(ip)") + } + } + + func test_thatValidationRuleInvalidatesInput_whenInputIsEmpty() { + // given + let ipv4Rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + let ipv6Rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + // then + XCTAssertFalse(ipv4Rule.validate(input: "")) + XCTAssertFalse(ipv6Rule.validate(input: "")) + } + + func test_thatValidationRuleInvalidatesInput_whenInputIsOnlyWhitespace() { + // given + let ipv4Rule = IPAddressValidationRule(version: .v4, error: "Invalid IPv4") + let ipv6Rule = IPAddressValidationRule(version: .v6, error: "Invalid IPv6") + + let whitespaceStrings = [ + " ", + " ", + "\t", + "\n", + "\r\n", + " \t \n ", + ] + + // then + for input in whitespaceStrings { + XCTAssertFalse(ipv4Rule.validate(input: input), "Expected invalid for whitespace") + XCTAssertFalse(ipv6Rule.validate(input: input), "Expected invalid for whitespace") + } + } +}