diff --git a/build.sbt b/build.sbt index ae8d30dc..3f1b5a6c 100644 --- a/build.sbt +++ b/build.sbt @@ -25,7 +25,12 @@ ThisBuild / initialCommands := "import com.comcast.ip4s._" ThisBuild / mimaBinaryIssueFilters ++= Seq( ProblemFilters.exclude[DirectMissingMethodProblem]("com.comcast.ip4s.Ipv6Address.toInetAddress"), - ProblemFilters.exclude[ReversedMissingMethodProblem]("com.comcast.ip4s.Dns.*") // sealed trait + ProblemFilters.exclude[ReversedMissingMethodProblem]("com.comcast.ip4s.Dns.*"), // sealed trait + // Scala 3 (erroneously?) considered Multicast/SourceSpecificMulticast as sum types + ProblemFilters.exclude[DirectMissingMethodProblem]("com.comcast.ip4s.Multicast.ordinal"), + ProblemFilters.exclude[MissingTypesProblem]("com.comcast.ip4s.Multicast$"), + ProblemFilters.exclude[DirectMissingMethodProblem]("com.comcast.ip4s.SourceSpecificMulticast.ordinal"), + ProblemFilters.exclude[MissingTypesProblem]("com.comcast.ip4s.SourceSpecificMulticast$") ) lazy val root = tlCrossRootProject.aggregate(core, testKit) @@ -93,8 +98,8 @@ lazy val docs = project .settings( mdocIn := baseDirectory.value / "src", mdocOut := baseDirectory.value / "../docs", - crossScalaVersions := (ThisBuild / crossScalaVersions).value.filter(_.startsWith("2.")), - githubWorkflowArtifactUpload := false + githubWorkflowArtifactUpload := false, + libraryDependencies += "org.typelevel" %%% "cats-effect" % "3.3.11" ) lazy val commonSettings = Seq( diff --git a/docs/guide.md b/docs/guide.md index db6fee22..59058b77 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -102,7 +102,7 @@ val homeIA = ip"127.0.0.1".toInetAddress val home4IA = ipv4"127.0.0.1".toInetAddress // home4IA: java.net.Inet4Address = /127.0.0.1 val home6IA = ipv6"::1".toInetAddress -// home6IA: java.net.Inet6Address = /0:0:0:0:0:0:0:1 +// home6IA: java.net.InetAddress = /0:0:0:0:0:0:0:1 ``` # Multicast @@ -135,9 +135,18 @@ To construct instances of `Multicast[A]` and `SourceSpecificMulticast[A]`, we ca val multicastIps = ips.flatMap(_.asMulticast) // multicastIps: List[com.comcast.ip4s.Multicast[IpAddress]] = List(224.10.10.10, 232.11.11.11, ff00::10, ff3b::11) val ssmIps = ips.flatMap(_.asSourceSpecificMulticast) -// ssmIps: List[com.comcast.ip4s.SourceSpecificMulticast[IpAddress]] = List(232.11.11.11, ff3b::11) +// ssmIps: List[SourceSpecificMulticast.Strict[IpAddress]] = List(232.11.11.11, ff3b::11) ``` +It's common for source specific multicast to be used with group addresses outside the designated source specific multicast address range. To support such cases, use `asSourceSpecificMulticastLenient`: + +```scala +val lenient = ips.flatMap(_.asSourceSpecificMulticastLenient) +// lenient: List[com.comcast.ip4s.SourceSpecificMulticast[IpAddress]] = List(224.10.10.10, 232.11.11.11, ff00::10, ff3b::11) +``` + +Additionally, the `SourceSpecificMulticast.Strict[A]` type provides the guarantee that the wrapped address is in the RFC defined source specific range. + ## Multicast Literals There are string interpolators for constructing multicast and source specific multicast address from literal strings, similar to the `ip`, `ipv4`, and `ipv6` interpolators. The multicast interpolators are: @@ -259,7 +268,7 @@ On the JVM, a `SocketAddress` can be converted to a `java.net.InetSocketAddress` ```scala val u = t.toInetSocketAddress -// u: java.net.InetSocketAddress = /0:0:0:0:0:0:0:1:5555 +// u: java.net.InetSocketAddress = /[0:0:0:0:0:0:0:1]:5555 ``` ## Multicast Socket Addresses diff --git a/docs/src/guide.md b/docs/src/guide.md index 5f9ab3aa..111df8cd 100644 --- a/docs/src/guide.md +++ b/docs/src/guide.md @@ -114,6 +114,14 @@ val multicastIps = ips.flatMap(_.asMulticast) val ssmIps = ips.flatMap(_.asSourceSpecificMulticast) ``` +It's common for source specific multicast to be used with group addresses outside the designated source specific multicast address range. To support such cases, use `asSourceSpecificMulticastLenient`: + +```scala mdoc:nest:to-string +val lenient = ips.flatMap(_.asSourceSpecificMulticastLenient) +``` + +Additionally, the `SourceSpecificMulticast.Strict[A]` type provides the guarantee that the wrapped address is in the RFC defined source specific range. + ## Multicast Literals There are string interpolators for constructing multicast and source specific multicast address from literal strings, similar to the `ip`, `ipv4`, and `ipv6` interpolators. The multicast interpolators are: diff --git a/shared/src/main/scala-2/Literals.scala b/shared/src/main/scala-2/Literals.scala index a592ea34..bb640690 100644 --- a/shared/src/main/scala-2/Literals.scala +++ b/shared/src/main/scala-2/Literals.scala @@ -87,7 +87,7 @@ object Literals { def make(c: Context)(args: c.Expr[Any]*): c.Expr[Multicast[Ipv6Address]] = apply(c)(args: _*) } - object ssmip extends Literally[SourceSpecificMulticast[IpAddress]] { + object ssmip extends Literally[SourceSpecificMulticast.Strict[IpAddress]] { def validate(c: Context)(s: String) = { import c.universe._ IpAddress.fromString(s).flatMap(_.asSourceSpecificMulticast) match { @@ -96,10 +96,10 @@ object Literals { case None => Left("invalid source specific IP multicast address") } } - def make(c: Context)(args: c.Expr[Any]*): c.Expr[SourceSpecificMulticast[IpAddress]] = apply(c)(args: _*) + def make(c: Context)(args: c.Expr[Any]*): c.Expr[SourceSpecificMulticast.Strict[IpAddress]] = apply(c)(args: _*) } - object ssmipv4 extends Literally[SourceSpecificMulticast[Ipv4Address]] { + object ssmipv4 extends Literally[SourceSpecificMulticast.Strict[Ipv4Address]] { def validate(c: Context)(s: String) = { import c.universe._ Ipv4Address.fromString(s).flatMap(_.asSourceSpecificMulticast) match { @@ -108,10 +108,10 @@ object Literals { case None => Left("invalid source specific IPv4 multicast address") } } - def make(c: Context)(args: c.Expr[Any]*): c.Expr[SourceSpecificMulticast[Ipv4Address]] = apply(c)(args: _*) + def make(c: Context)(args: c.Expr[Any]*): c.Expr[SourceSpecificMulticast.Strict[Ipv4Address]] = apply(c)(args: _*) } - object ssmipv6 extends Literally[SourceSpecificMulticast[Ipv6Address]] { + object ssmipv6 extends Literally[SourceSpecificMulticast.Strict[Ipv6Address]] { def validate(c: Context)(s: String) = { import c.universe._ Ipv6Address.fromString(s).flatMap(_.asSourceSpecificMulticast) match { @@ -120,7 +120,7 @@ object Literals { case None => Left("invalid source specific IPv6 multicast address") } } - def make(c: Context)(args: c.Expr[Any]*): c.Expr[SourceSpecificMulticast[Ipv6Address]] = apply(c)(args: _*) + def make(c: Context)(args: c.Expr[Any]*): c.Expr[SourceSpecificMulticast.Strict[Ipv6Address]] = apply(c)(args: _*) } object port extends Literally[Port] { diff --git a/shared/src/main/scala-2/package.scala b/shared/src/main/scala-2/package.scala index 1b511197..4826c8f9 100644 --- a/shared/src/main/scala-2/package.scala +++ b/shared/src/main/scala-2/package.scala @@ -31,11 +31,11 @@ package object ip4s extends ip4splatform { def mipv6(args: Any*): Multicast[Ipv6Address] = macro Literals.mipv6.make - def ssmip(args: Any*): SourceSpecificMulticast[IpAddress] = + def ssmip(args: Any*): SourceSpecificMulticast.Strict[IpAddress] = macro Literals.ssmip.make - def ssmipv4(args: Any*): SourceSpecificMulticast[Ipv4Address] = + def ssmipv4(args: Any*): SourceSpecificMulticast.Strict[Ipv4Address] = macro Literals.ssmipv4.make - def ssmipv6(args: Any*): SourceSpecificMulticast[Ipv6Address] = + def ssmipv6(args: Any*): SourceSpecificMulticast.Strict[Ipv6Address] = macro Literals.ssmipv6.make def port(args: Any*): Port = diff --git a/shared/src/main/scala-3/Literals.scala b/shared/src/main/scala-3/Literals.scala index 89cf60d8..05e2621c 100644 --- a/shared/src/main/scala-3/Literals.scala +++ b/shared/src/main/scala-3/Literals.scala @@ -37,13 +37,13 @@ extension (inline ctx: StringContext) inline def mipv6(inline args: Any*): Multicast[Ipv6Address] = ${ Literals.mipv6('ctx, 'args) } - inline def ssmip(inline args: Any*): SourceSpecificMulticast[IpAddress] = + inline def ssmip(inline args: Any*): SourceSpecificMulticast.Strict[IpAddress] = ${ Literals.ssmip('ctx, 'args) } - inline def ssmipv4(inline args: Any*): SourceSpecificMulticast[Ipv4Address] = + inline def ssmipv4(inline args: Any*): SourceSpecificMulticast.Strict[Ipv4Address] = ${ Literals.ssmipv4('ctx, 'args) } - inline def ssmipv6(inline args: Any*): SourceSpecificMulticast[Ipv6Address] = + inline def ssmipv6(inline args: Any*): SourceSpecificMulticast.Strict[Ipv6Address] = ${ Literals.ssmipv6('ctx, 'args) } inline def port(inline args: Any*): Port = @@ -93,25 +93,31 @@ object Literals: case Some(_) => Right('{ _root_.com.comcast.ip4s.Ipv6Address.fromString(${ Expr(s) }).get.asMulticast.get }) case None => Left("Invalid IPv6 multicast address") - object ssmip extends Literally[SourceSpecificMulticast[IpAddress]]: + object ssmip extends Literally[SourceSpecificMulticast.Strict[IpAddress]]: def validate(s: String)(using Quotes) = IpAddress.fromString(s).flatMap(_.asSourceSpecificMulticast) match case Some(_) => - Right('{ _root_.com.comcast.ip4s.IpAddress.fromString(${ Expr(s) }).get.asSourceSpecificMulticast.get }) + Right('{ + _root_.com.comcast.ip4s.IpAddress.fromString(${ Expr(s) }).get.asSourceSpecificMulticast.get + }) case None => Left("Invalid source specific IP multicast address") - object ssmipv4 extends Literally[SourceSpecificMulticast[Ipv4Address]]: + object ssmipv4 extends Literally[SourceSpecificMulticast.Strict[Ipv4Address]]: def validate(s: String)(using Quotes) = Ipv4Address.fromString(s).flatMap(_.asSourceSpecificMulticast) match case Some(_) => - Right('{ _root_.com.comcast.ip4s.Ipv4Address.fromString(${ Expr(s) }).get.asSourceSpecificMulticast.get }) + Right('{ + _root_.com.comcast.ip4s.Ipv4Address.fromString(${ Expr(s) }).get.asSourceSpecificMulticast.get + }) case None => Left("Invalid source specific IPv4 multicast address") - object ssmipv6 extends Literally[SourceSpecificMulticast[Ipv6Address]]: + object ssmipv6 extends Literally[SourceSpecificMulticast.Strict[Ipv6Address]]: def validate(s: String)(using Quotes) = Ipv6Address.fromString(s).flatMap(_.asSourceSpecificMulticast) match case Some(_) => - Right('{ _root_.com.comcast.ip4s.Ipv6Address.fromString(${ Expr(s) }).get.asSourceSpecificMulticast.get }) + Right('{ + _root_.com.comcast.ip4s.Ipv6Address.fromString(${ Expr(s) }).get.asSourceSpecificMulticast.get + }) case None => Left("Invalid source specific IPv6 multicast address") object port extends Literally[Port]: diff --git a/shared/src/main/scala/com/comcast/ip4s/Host.scala b/shared/src/main/scala/com/comcast/ip4s/Host.scala index 9761d656..d2c28c99 100644 --- a/shared/src/main/scala/com/comcast/ip4s/Host.scala +++ b/shared/src/main/scala/com/comcast/ip4s/Host.scala @@ -175,9 +175,13 @@ sealed abstract class IpAddress extends IpAddressPlatform with Host with Seriali /** Converts this address to a source specific multicast address, as long as it is in the source specific multicast * address range. */ - def asSourceSpecificMulticast: Option[SourceSpecificMulticast[this.type]] = + def asSourceSpecificMulticast: Option[SourceSpecificMulticast.Strict[this.type]] = SourceSpecificMulticast.fromIpAddress(this) + /** Like `asSourceSpecificMulticast` but allows multicast addresses outside the source specific range. */ + def asSourceSpecificMulticastLenient: Option[SourceSpecificMulticast[this.type]] = + SourceSpecificMulticast.fromIpAddressLenient(this) + /** Narrows this address to an Ipv4Address if that is the underlying type. */ def asIpv4: Option[Ipv4Address] = collapseMappedV4.fold(Some(_), _ => None) diff --git a/shared/src/main/scala/com/comcast/ip4s/Multicast.scala b/shared/src/main/scala/com/comcast/ip4s/Multicast.scala index 9d07c3a9..af44cd6f 100644 --- a/shared/src/main/scala/com/comcast/ip4s/Multicast.scala +++ b/shared/src/main/scala/com/comcast/ip4s/Multicast.scala @@ -30,6 +30,11 @@ sealed trait Multicast[+A <: IpAddress] extends Product with Serializable { object Multicast { private case class DefaultMulticast[+A <: IpAddress](address: A) extends Multicast[A] { override def toString: String = address.toString + override def equals(that: Any): Boolean = that match { + case m: Multicast[_] => address == m.address + case _ => false + } + override def hashCode: Int = address.hashCode } /** Constructs a multicast IP address. Returns `None` is the supplied address is not in the valid multicast range. */ @@ -47,27 +52,50 @@ object Multicast { /** Witnesses that the wrapped address of type `A` is a source specific multicast address. * - * An instance of `SourceSpecificMulticast` is typically created by either calling `Multicast.apply` or by using the - * `asSourceSpecificMulticast` method on `IpAddress`. + * An instance of `SourceSpecificMulticast` is typically created by either calling `Multicast.apply` or by using + * `asSourceSpecificMulticast` and `asSourceSpecificMulticastLenient` methods on `IpAddress`. */ sealed trait SourceSpecificMulticast[+A <: IpAddress] extends Multicast[A] { + + /** Ensures the referenced address is in the RFC defined source specific address range. */ + def strict: Option[SourceSpecificMulticast.Strict[A]] = + if (address.isSourceSpecificMulticast) Some(SourceSpecificMulticast.unsafeCreateStrict(address)) else None + override def toString: String = address.toString } object SourceSpecificMulticast { + + /** Indicates the address is within the RFC defined source specific multicast range. */ + trait Strict[+A <: IpAddress] extends SourceSpecificMulticast[A] + private case class DefaultSourceSpecificMulticast[+A <: IpAddress](address: A) extends SourceSpecificMulticast[A] { override def toString: String = address.toString + override def equals(that: Any): Boolean = that match { + case m: Multicast[_] => address == m.address + case _ => false + } + override def hashCode: Int = address.hashCode } - /** Constructs a source specific multicast IP address. Returns `None` is the supplied address is not in the valid + /** Constructs a source specific multicast IP address. Returns `None` if the supplied address is not in the valid * source specific multicast range. */ - def fromIpAddress[A <: IpAddress](address: A): Option[SourceSpecificMulticast[A]] = - if (address.isSourceSpecificMulticast) Some(new DefaultSourceSpecificMulticast(address)) else None + def fromIpAddress[A <: IpAddress](address: A): Option[SourceSpecificMulticast.Strict[A]] = + if (address.isSourceSpecificMulticast) Some(unsafeCreateStrict(address)) else None + + /** Constructs a source specific multicast IP address. Unlike `fromIpAddress`, multicast addresses outside the RFC + * defined source specific range are allowed. + */ + def fromIpAddressLenient[A <: IpAddress](address: A): Option[SourceSpecificMulticast[A]] = + if (address.isMulticast) Some(unsafeCreate(address)) else None private[ip4s] def unsafeCreate[A <: IpAddress](address: A): SourceSpecificMulticast[A] = DefaultSourceSpecificMulticast(address) + private[ip4s] def unsafeCreateStrict[A <: IpAddress](address: A): SourceSpecificMulticast.Strict[A] = + new DefaultSourceSpecificMulticast(address) with Strict[A] + implicit def ordering[A <: IpAddress]: Ordering[SourceSpecificMulticast[A]] = Multicast.ordering[SourceSpecificMulticast, A] } diff --git a/shared/src/main/scala/com/comcast/ip4s/MulticastJoin.scala b/shared/src/main/scala/com/comcast/ip4s/MulticastJoin.scala index ce967e65..413863dc 100644 --- a/shared/src/main/scala/com/comcast/ip4s/MulticastJoin.scala +++ b/shared/src/main/scala/com/comcast/ip4s/MulticastJoin.scala @@ -80,11 +80,11 @@ object MulticastJoin { case Some(sourceStr) => for { source <- parse(sourceStr) - group <- parse(groupStr).flatMap(_.asSourceSpecificMulticast) + group <- parse(groupStr).flatMap(_.asSourceSpecificMulticastLenient) } yield ssm(source, group) case None => for { - group <- parse(groupStr).flatMap(_.asSourceSpecificMulticast) + group <- parse(groupStr).flatMap(_.asSourceSpecificMulticastLenient) } yield asm(group) } case _ => None diff --git a/shared/src/main/scala/com/comcast/ip4s/MulticastSocketAddress.scala b/shared/src/main/scala/com/comcast/ip4s/MulticastSocketAddress.scala index e84e605d..aecfaea2 100644 --- a/shared/src/main/scala/com/comcast/ip4s/MulticastSocketAddress.scala +++ b/shared/src/main/scala/com/comcast/ip4s/MulticastSocketAddress.scala @@ -88,7 +88,7 @@ object MulticastSocketAddress { case Some(sourceStr) => for { source <- parse(sourceStr) - group <- parse(groupStr).flatMap(_.asSourceSpecificMulticast) + group <- parse(groupStr).flatMap(_.asSourceSpecificMulticastLenient) port <- Port.fromString(portStr) } yield MulticastSocketAddress(MulticastJoin.ssm(source, group), port) case None => diff --git a/test-kit/shared/src/main/scala/com/comcast/ip4s/Arbitraries.scala b/test-kit/shared/src/main/scala/com/comcast/ip4s/Arbitraries.scala index c21878fc..796399b2 100644 --- a/test-kit/shared/src/main/scala/com/comcast/ip4s/Arbitraries.scala +++ b/test-kit/shared/src/main/scala/com/comcast/ip4s/Arbitraries.scala @@ -80,8 +80,9 @@ object Arbitraries { def multicastJoinGenerator[A <: IpAddress](genSource: Gen[A], genGroup: Gen[Multicast[A]]): Gen[MulticastJoin[A]] = genGroup.flatMap { group => group.address.asSourceSpecificMulticast match { - case Some(grp) => genSource.filter(_.getClass == grp.getClass).flatMap(src => MulticastJoin.ssm(src, grp)) - case None => MulticastJoin.asm(group) + case Some(grp) => + genSource.filter(_.getClass == grp.address.getClass).flatMap(src => MulticastJoin.ssm(src, grp)) + case None => MulticastJoin.asm(group) } } diff --git a/test-kit/shared/src/test/scala/com/comcast/ip4s/MulticastTest.scala b/test-kit/shared/src/test/scala/com/comcast/ip4s/MulticastTest.scala index ebe013c8..b715b91a 100644 --- a/test-kit/shared/src/test/scala/com/comcast/ip4s/MulticastTest.scala +++ b/test-kit/shared/src/test/scala/com/comcast/ip4s/MulticastTest.scala @@ -23,8 +23,16 @@ class MulticastTest extends BaseTestSuite { test("support equality") { forAll { (mip: Multicast[IpAddress]) => assertEquals(mip.address.asMulticast, Some(mip)) - mip.address.asSourceSpecificMulticast.foreach(x => assert(x == mip)) - mip.address.asSourceSpecificMulticast.foreach(x => assertEquals(mip, x)) + mip.address.asSourceSpecificMulticastLenient.foreach(x => assertEquals(mip, x)) + mip.address.asSourceSpecificMulticastLenient.foreach(x => assert(x == mip)) } } + + test("support SSM outside source specific range") { + assertEquals(ip"239.10.10.10".asSourceSpecificMulticast, None) + assertEquals( + ip"239.10.10.10".asSourceSpecificMulticastLenient, + Some(SourceSpecificMulticast.unsafeCreate(ip"239.10.10.10")) + ) + } }