diff --git a/src/main/scala/run/cosy/http/headers/Rfc8941.scala b/src/main/scala/run/cosy/http/headers/Rfc8941.scala index be12686..3b3a102 100644 --- a/src/main/scala/run/cosy/http/headers/Rfc8941.scala +++ b/src/main/scala/run/cosy/http/headers/Rfc8941.scala @@ -1,7 +1,9 @@ package run.cosy.http.headers +import run.cosy.http.headers.Rfc8941.Serialise.Serialise + import java.util.Base64 -import scala.collection.immutable.{ArraySeq,ListMap} +import scala.collection.immutable.{ArraySeq, ListMap} object Rfc8941 { @@ -59,12 +61,12 @@ object Rfc8941 { val key: P[Token] = ((lcalpha | `*`) ~ (lcalpha | R5234.digit | P.charIn('_', '-', '.', '*')).rep0) .map((c, lc) => Token((c :: lc).mkString)) - val parameter: P[Parameter] = + val parameter: P[Param] = (key ~ (P.char('=') *> bareItem).orElse(P.pure(true))) //note: parameters always returns an answer (the empty list) as everything can have parameters //todo: this is not exeactly how it is specified, so check here if something goes wrong - val parameters: P0[Parameters] = + val parameters: P0[Params] = (P.char(';') *> ows *> parameter).rep0.orElse(P.pure(List())).map { list => ListMap.from[Token, Item](list.iterator) } @@ -79,12 +81,12 @@ object Rfc8941 { case (None, params) => IList(List(), params) } } - val listMember: P[Parameterized] = (sfItem | innerList) + val listMember: P[Paramed] = (sfItem | innerList) val sfList: P[SfList] = (listMember ~ ((ows *> P.char(',') *> ows).void.with1 *> listMember).rep0).map((p, lp) => p :: lp) - val memberValue: P[Parameterized] = (sfItem | innerList) + val memberValue: P[Paramed] = (sfItem | innerList) //note: we have to go with parsing `=` first as parameters always returns an answer. val dictMember: P[DictMember] = (key ~ (P.char('=') *> memberValue).eitherOr(parameters)) .map { @@ -111,49 +113,56 @@ object Rfc8941 { object Serialise { trait Serialise[-T]: - extension (t: T) + extension (o: T) //may be better if encoded directly to a byte string def canon: String - given boolSer: Serialise[Boolean] with - extension (t: Boolean) - def canon: String = if t then "?1" else "?0" - - given byteSer: Serialise[ArraySeq[Byte]] with - extension (t: ArraySeq[Byte]) - def canon: String = ":"+Base64.getEncoder - .encodeToString(t.unsafeArray.asInstanceOf[Array[Byte]]) - - given tokenSer: Serialise[Token] with - extension (t: Token) - def canon: String = t.t - - given stringSer: Serialise[String] with - extension (t: String) - def canon: String = s""""$t"""" - - given numberSer: Serialise[Number] with - extension (t: Number) - def canon: String = t match + given itemSer: Serialise[Item] with + extension (o: Item) + def canon: String = o match case i: IntStr => i.integer + //todo: https://www.rfc-editor.org/rfc/rfc8941.html#ser-decimal case d: DecStr => d.integer + "." + d.dec + case s: String => s""""$s"""" + case tk: Token => tk.t + case as: ArraySeq[Byte] => ":"+Base64.getEncoder + .encodeToString(as.unsafeArray.asInstanceOf[Array[Byte]])+":" + case b: Boolean => if b then "?1" else "?0" // // complex types // - given paramSer[A<:Item](using Serialise[A]): Serialise[(Token,A)] with - extension (t: (Token,A)) - def canon: String = ";"+t._1.canon + {t._2 match { + + given paramSer(using Serialise[Item]): Serialise[Param] with + extension (o: Param) + def canon: String = ";"+o._1.canon + {o._2 match { case b: Boolean => "" - case other => other.canon + case other => "="+other.canon }} + given paramsSer(using Serialise[Param]): Serialise[Params] with + extension (o: Params) + def canon: String = o.map(_.canon).mkString + + given paramItemSer(using + Serialise[Item], Serialise[Params] + ): Serialise[PItem] with + extension (o: PItem) + def canon: String = o.item.canon + o.params.canon + + given sfListSer(using + Serialise[Item], Serialise[Params] + ): Serialise[IList] with + extension (o: IList) + def canon: String = + o.items.map(i=>i.canon).mkString("("," ",")")+o.params.canon + } // //types uses by parser above // - - sealed abstract class Parameterized //would need to use http4s to get Renderable + /** trait for Parameterizes types */ + sealed abstract class Paramed //would need to use http4s to get Renderable /** * see [[https://www.rfc-editor.org/rfc/rfc8941.html#section-3.3 §3.3 Items]] of RFC8941. @@ -161,13 +170,15 @@ object Rfc8941 { * So one should narrow the classes or be careful on serialisation, i.e. header construction. * Note: Unit was added. It Allows us to have empty Item parameters. todo: check it's ok. */ - type Item = Number | String | Token | ArraySeq[Byte] | Boolean - type Parameter = (Token, Item) - type Parameters = ListMap[Token, Item] - type SfList = List[Parameterized] - type SfDict = ListMap[Token, Parameterized|Parameters] - - def SfDict(entries: (Token,Parameterized|Parameters)*) = ListMap(entries*) + type Item = Number | String | Token | ArraySeq[Byte] | Boolean + type Param = (Token, Item) + type Params = ListMap[Token, Item] + type SfList = List[Paramed] + type SfDict = ListMap[Token, Paramed|Params] + + def Param(tk: String, i: Item): Param = (Token(tk),i) + def Params(ps: Param*): Params = ListMap(ps*) + def SfDict(entries: (Token,Paramed|Params)*) = ListMap(entries*) /** * dict-member = member-key ( parameters / ( "=" member-value )) * member-value = sf-item / inner-list @@ -176,27 +187,26 @@ object Rfc8941 { * @param values if InnerList with an empty list, then we have "parameters", else we have an inner list */ final - case class DictMember(key: Token, values: Parameterized|Parameters) + case class DictMember(key: Token, values: Paramed|Params) /** Parameterized Item */ final - case class PItem(item: Item, params: Parameters) extends Parameterized + case class PItem(item: Item, params: Params) extends Paramed object PItem { def apply(item: Item): PItem = new PItem(item,ListMap()) - def apply(item: Item, params: Parameters): PItem = new PItem(item, params) - def apply(item: Item)(params: Parameter*): PItem = new PItem(item,ListMap(params*)) + def apply(item: Item)(params: Param*): PItem = new PItem(item,ListMap(params*)) } /** Inner List */ final - case class IList(items: List[PItem], params: Parameters) extends Parameterized + case class IList(items: List[PItem], params: Params) extends Paramed object IList { - def apply(items: PItem*)(params: Parameter*): IList = new IList(items.toList,ListMap(params*)) + def apply(items: PItem*)(params: Param*): IList = new IList(items.toList,ListMap(params*)) } - trait Number + sealed trait Number // todo: could one use List[Digit] instead of String, to avoid loosing type info? // todo: arguably Long would do just fine here too. @@ -206,12 +216,14 @@ object Rfc8941 { //by not interpreting at this point. There may be a way not to loose that, but it would require //quite a lot of digging into the BigDecimal class' functioning. That would also tie this layer //to that choice, unless such choices could be passed `using` ops. + //todo: details of how to map to BigDecimal are + // here https://www.rfc-editor.org/rfc/rfc8941.html#ser-decimal final case class DecStr(integer: String, dec: String) extends Number final case class Token(t: String) implicit val token2PI: Conversion[Item,PItem] = (i: Item) => PItem(i) - private def paramConversion(paras: Parameter*): Parameters = ListMap(paras*) - implicit val paramConv: Conversion[Seq[Parameter],Parameters] = paramConversion + private def paramConversion(paras: Param*): Params = ListMap(paras*) + implicit val paramConv: Conversion[Seq[Param],Params] = paramConversion } \ No newline at end of file diff --git a/src/main/scala/run/cosy/http/headers/Signature-Input.scala b/src/main/scala/run/cosy/http/headers/Signature-Input.scala index 440c465..10cf88c 100644 --- a/src/main/scala/run/cosy/http/headers/Signature-Input.scala +++ b/src/main/scala/run/cosy/http/headers/Signature-Input.scala @@ -71,7 +71,7 @@ case class SigInput(headers: Seq[HeaderSelector], att: SigAttributes) { * We lump the parameters together as there may be some we can't interpret, * but we requure keyId to be present. **/ -class SigAttributes(keyId: String, params: Rfc8941.Parameters) { +class SigAttributes(keyId: String, params: Rfc8941.Params) { //for both created and expires we return the time only if the types are correct, otherwise we ignore. def created: Option[Long] = params.get(Token("created")).collect{case IntStr(num) => num.toLong} def expires: Option[Long] = params.get(Token("expires")).collect{case IntStr(num) => num.toLong} diff --git a/src/test/scala/run/cosy/http/headers/Rfc8941_Test.scala b/src/test/scala/run/cosy/http/headers/Rfc8941_Test.scala index 706d52e..27fe315 100644 --- a/src/test/scala/run/cosy/http/headers/Rfc8941_Test.scala +++ b/src/test/scala/run/cosy/http/headers/Rfc8941_Test.scala @@ -5,7 +5,7 @@ import cats.parse.Parser.{Expectation, Fail} import cats.data.NonEmptyList import run.cosy.http.headers.Rfc8941 import Rfc8941.Parser.{dictMember, sfBinary, sfBoolean, sfDecimal, sfDictionary, sfInteger, sfList, sfNumber, sfString, sfToken} -import run.cosy.http.headers.Rfc8941.SfDict +import run.cosy.http.headers.Rfc8941.{IList, Param, Params, SfDict} import java.util.Base64 import scala.collection.immutable.{ArraySeq, ListMap} @@ -199,12 +199,12 @@ class Rfc8941_Test extends munit.FunSuite { Token("d") -> IL(PI(IntStr("5")),PI(IntStr("6")))(Token("valid")->true) ))) } - + + import run.cosy.ldp.testUtils.StringUtils._ //examples are taken from https://tools.ietf.org/html/draft-ietf-httpbis-message-signatures-03 test("sfDictionary with Signing Http Messages headers") { //here we start playing with making the syntax easier to work with by using implicit conversions import scala.language.implicitConversions - import run.cosy.ldp.testUtils.StringUtils._ val `ex§4.1` = """sig1=("@request-target" "host" "date" "cache-control" \ | "x-empty-header" "x-example"); keyid="test-key-a"; \ @@ -256,13 +256,57 @@ class Rfc8941_Test extends munit.FunSuite { assertEquals(IntStr("234").canon, "234") assertEquals("hello".canon, """"hello"""") assertEquals(DecStr("1024","48").canon,"1024.48") - assertEquals(cafebabe.canon,":cafebabe") - assertEquals(cafedead.canon,":cafedead") + assertEquals(cafebabe.canon,":cafebabe:") + assertEquals(cafedead.canon,":cafedead:") } - - test("serialisation of Parameter") { - assertEquals((Token("fun"),true).canon,";fun") - + import Rfc8941.{Token=>Tk} + test("serialisation of Parameterized Items") { + assertEquals(Param("fun",true).canon,";fun") + assertEquals(Params(Tk("fun")->true).canon,";fun") + assertEquals( + Params(Tk("foo")->true, Tk("bar")->IntStr("42")).canon, + ";foo;bar=42") + assertEquals( + Params(Tk("foo")->true, Tk("bar")->IntStr("42"),Tk("baz")->"hello").canon, + """;foo;bar=42;baz="hello"""") + assertEquals( + Params(Tk("keyid")->cafebabe).canon, + ";keyid=:cafebabe:" + ) + assertEquals( + PItem(Tk("*foo"))(Tk("age")->IntStr("33")).canon, + "*foo;age=33" + ) + assertEquals( + PItem(DecStr("99","999"))(Tk("discount")-> DecStr("0","2")).canon, + "99.999;discount=0.2" + ) + assertEquals( + PItem(cafebabe)(Tk("enc")-> "utf8").canon, + """:cafebabe:;enc="utf8"""" + ) + } + test("serialisation of List") { + import scala.language.implicitConversions + //example from + // https://tools.ietf.org/html/draft-ietf-httpbis-message-signatures-03#section-2.4.2.1 + // but whitespaces between attributes have been removed as per + // issue: https://github.com/httpwg/http-extensions/issues/1456 + assertEquals( + IList("@request-target", "host", "date","cache-control","x-empty-header", "x-example", + PItem("x-dictionary")(Param("key",Tk("b"))), + PItem("x-dictionary")(Param("key",Tk("a"))), + PItem("x-list")(Param("prefix",IntStr("3"))))( + Param("keyid","test-key-a"), + Param("alg","rsa-pss-sha512"), + Param("created",IntStr("1402170695")), + Param("expires",IntStr("1402170995")), + ).canon, + """("@request-target" "host" "date" "cache-control" \ + | "x-empty-header" "x-example" "x-dictionary";key=b \ + | "x-dictionary";key=a "x-list";prefix=3);keyid="test-key-a";\ + | alg="rsa-pss-sha512";created=1402170695;expires=1402170995""".rfc8792single + ) } }