diff --git a/gleam.toml b/gleam.toml index 0146f97..35c3142 100644 --- a/gleam.toml +++ b/gleam.toml @@ -9,6 +9,7 @@ repository = { type = "github", user = "Beaudidly", repo = "gbor" } gleam_stdlib = ">= 0.44.0 and < 2.0.0" ieee_float = ">= 1.5.0 and < 2.0.0" gleam_erlang = ">= 1.2.0 and < 2.0.0" +gleam_time = ">= 1.3.0 and < 2.0.0" [dev-dependencies] gleeunit = ">= 1.0.0 and < 2.0.0" diff --git a/src/gbor.gleam b/src/gbor.gleam index c6fb4e1..701f5bd 100644 --- a/src/gbor.gleam +++ b/src/gbor.gleam @@ -7,6 +7,8 @@ /// > **Note**: This type may become opaque in future major types as we evaluate /// > the API needs for this package. /// +import gleam/time/timestamp.{type Timestamp} + pub type CBOR { CBInt(Int) CBString(String) @@ -18,6 +20,7 @@ pub type CBOR { CBUndefined CBBinary(BitArray) CBTagged(Int, CBOR) + CBTime(Timestamp) } // Placeholder for LSP diff --git a/src/gbor/decode.gleam b/src/gbor/decode.gleam index b42ea72..0285e80 100644 --- a/src/gbor/decode.gleam +++ b/src/gbor/decode.gleam @@ -5,10 +5,13 @@ import gleam/bool import gleam/dynamic import gleam/dynamic/decode as gdd import gleam/erlang/atom +import gleam/float import gleam/int import gleam/list import gleam/result import gleam/string +import gleam/time/duration +import gleam/time/timestamp import ieee_float as i @@ -39,24 +42,29 @@ pub fn cbor_to_dynamic(cbor: g.CBOR) -> dynamic.Dynamic { g.CBUndefined -> dynamic.nil() g.CBBinary(v) -> dynamic.bit_array(v) g.CBTagged(tag, value) -> ffi_to_tagged(tag, cbor_to_dynamic(value)) + g.CBTime(v) -> + ffi_to_tagged( + 0, + dynamic.string(timestamp.to_rfc3339(v, duration.hours(0))), + ) } } /// Decode a tagged CBOR value. -/// +/// /// Provided tag is the expected tag number for the value, and the decoder is run /// on the value corresponding to the tag. -/// +/// /// For example, for a CBOR value with a tag number of `0`, the expected data item /// is a text string representing a standard time string, so one would call: -/// +/// /// ```gleam /// import gleam/dynamic/decode as gdd /// tagged_decoder(0, gdd.string, "") /// ``` -/// +/// /// Reference: [RFC 8949 : 3.4 Tagging of Items](https://www.rfc-editor.org/rfc/rfc8949.html#name-tagging-of-items) -/// +/// pub fn tagged_decoder( expected_tag: Int, decoder: gdd.Decoder(a), @@ -78,11 +86,11 @@ pub fn tagged_decoder( } /// Decode a CBOR value from a bit array -/// +/// /// This function is the main entry point for decoding CBOR data into Gleam types. -/// +/// /// It takes a bit array and returns a result containing the decoded CBOR value -/// +/// pub fn from_bit_array(data: BitArray) -> Result(g.CBOR, CborDecodeError) { case decode_helper(data) { Ok(#(v, <<>>)) -> Ok(v) @@ -392,8 +400,53 @@ fn decode_tagged( }) use #(tag_value, rest) <- result.try(decode_helper(rest)) + case tag_num { + 0 | 1 | 2 | 3 -> + case decode_low_tag(tag_num, tag_value) { + Ok(val) -> Ok(#(val, rest)) + Error(Nil) -> Error(MajorTypeError(6)) + } + _ -> { + Ok(#(g.CBTagged(tag_num, tag_value), rest)) + } + } +} + +fn decode_low_tag(min: Int, value: g.CBOR) -> Result(g.CBOR, Nil) { + case min, value { + 0, g.CBString(datetime) -> decode_datetime(datetime) + 1, g.CBInt(time) -> Ok(g.CBTime(timestamp.from_unix_seconds(time))) + 1, g.CBFloat(time) -> Ok(g.CBTime(decode_timestamp(time))) + 2, g.CBBinary(value) -> { + let size = bit_array.byte_size(value) + case value { + <> -> Ok(g.CBInt(res)) + _ -> Error(Nil) + } + } + 3, g.CBBinary(value) -> + case decode_low_tag(2, g.CBBinary(value)) { + Ok(g.CBInt(bigint)) -> Ok(g.CBInt({ -bigint } - 1)) + u -> u + } + _, _ -> Error(Nil) + } +} - Ok(#(g.CBTagged(tag_num, tag_value), rest)) +fn decode_datetime(dt: String) -> Result(g.CBOR, Nil) { + case timestamp.parse_rfc3339(dt) { + Ok(dt) -> Ok(g.CBTime(dt)) + _ -> Error(Nil) + } +} + +fn decode_timestamp(ts: Float) -> timestamp.Timestamp { + let seconds = float.floor(ts) + let nano = { ts -. seconds } *. 1.0e9 + timestamp.from_unix_seconds_and_nanoseconds( + float.truncate(seconds), + float.truncate(nano), + ) } @external(erlang, "erl_gbor", "to_tagged") diff --git a/src/gbor/encode.gleam b/src/gbor/encode.gleam index a9acb3c..a879c04 100644 --- a/src/gbor/encode.gleam +++ b/src/gbor/encode.gleam @@ -1,9 +1,12 @@ //// Module where we can find the functions used for getting the CBOR binary representation of a value import gleam/bit_array +import gleam/int import gleam/list import gleam/result import gleam/string +import gleam/time/duration +import gleam/time/timestamp import ieee_float as i @@ -28,6 +31,11 @@ pub fn to_bit_array(value: g.CBOR) -> Result(BitArray, EncodeError) { g.CBTagged(t, v) -> tagged_encode(t, v) g.CBNull -> Ok(null_encode()) g.CBUndefined -> Ok(undefined_encode()) + g.CBTime(v) -> + to_bit_array(g.CBTagged( + 0, + g.CBString(timestamp.to_rfc3339(v, duration.hours(0))), + )) } } @@ -39,7 +47,15 @@ fn uint_encode(value: Int) -> Result(BitArray, EncodeError) { v if v < 0x10000 -> Ok(<<0:3, 25:5, v:16>>) v if v < 0x100000000 -> Ok(<<0:3, 26:5, v:32>>) v if v < 0x10000000000000000 -> Ok(<<0:3, 27:5, v:64>>) - v -> Error(EncodeError("Int value too large: " <> string.inspect(v))) + v -> { + let l = string.length(int.to_base16(v)) + let l = l - { l / 2 } + case l < 0b100000 { + True -> Ok(<<6:3, 2:5, 2:3, l:5, v:size(l)-unit(8)>>) + False -> + Error(EncodeError("Int value too large: " <> string.inspect(v))) + } + } } } @@ -52,7 +68,17 @@ fn int_encode(value: Int) -> Result(BitArray, EncodeError) { v if v < 0x10000 -> Ok(<<1:3, 25:5, v:16>>) v if v < 0x100000000 -> Ok(<<1:3, 26:5, v:32>>) v if v < 0x10000000000000000 -> Ok(<<1:3, 27:5, v:64>>) - v -> Error(EncodeError("Int value too large: " <> string.inspect(v))) + v -> { + let l = string.length(int.to_base16(v)) + let l = l - { l / 2 } + case l < 0b100000 { + True -> Ok(<<6:3, 3:5, 2:3, l:5, v:size(l)-unit(8)>>) + False -> + Error(EncodeError( + "Absolute Int value too large: " <> string.inspect(v), + )) + } + } } } diff --git a/test/round_trip_test.gleam b/test/round_trip_test.gleam index cfd5711..7c7ab44 100644 --- a/test/round_trip_test.gleam +++ b/test/round_trip_test.gleam @@ -3,18 +3,18 @@ //// Negative values //// Special values (Infinity, NaN) +import gbor as g +import gbor/decode as d +import gbor/encode as e import gleam/bit_array import gleam/bool import gleam/dynamic/decode as gdd import gleam/list import gleam/result import gleam/string +import gleam/time/timestamp import gleeunit -import gbor as g -import gbor/decode as d -import gbor/encode as e - pub fn main() { gleeunit.main() } @@ -184,28 +184,27 @@ pub fn decode_simple_test() { } pub fn decode_taggded_test() { - let assert Ok(payload) = bit_array.base16_decode("010000000000000000") let assert Ok(_) = - round_trip(g.CBTagged(2, g.CBBinary(payload)), "c249010000000000000000") + round_trip(g.CBInt(18_446_744_073_709_551_616), "c249010000000000000000") - let assert Ok(payload) = bit_array.base16_decode("010000000000000000") let assert Ok(_) = - round_trip(g.CBTagged(3, g.CBBinary(payload)), "c349010000000000000000") + round_trip(g.CBInt(-18_446_744_073_709_551_617), "c349010000000000000000") + let assert Ok(timestamp) = timestamp.parse_rfc3339("2013-03-21T20:04:00Z") let assert Ok(_) = round_trip( - g.CBTagged(0, g.CBString("2013-03-21T20:04:00Z")), + g.CBTime(timestamp), "c074323031332d30332d32315432303a30343a30305a", ) - let assert Ok(_) = - round_trip(g.CBTagged(1, g.CBInt(1_363_896_240)), "c11a514b67b0") + // CBTagged(1, CBInt(1363896240)) <=> + // "c11a514b67b0" <=> + // "2013-03-21T20:04:00Z" + let assert Ok(_) = round_trip(g.CBTime(timestamp), "c11a514b67b0") - let assert Ok(_) = - round_trip( - g.CBTagged(1, g.CBFloat(1_363_896_240.5)), - "c1fb41d452d9ec200000", - ) + // CBTagged(1, CBFloat(1363896240.5)) <=> "c1fb41d452d9ec200000" + let assert Ok(timestamp) = timestamp.parse_rfc3339("2013-03-21T20:04:00.50Z") + let assert Ok(_) = round_trip(g.CBTime(timestamp), "c1fb41d452d9ec200000") let assert Ok(_) = round_trip( @@ -283,8 +282,9 @@ fn round_trip(expected: g.CBOR, hex: String) -> Result(Nil, String) { ), ) + // Time is always encoded to tag 0, so skip when decoded from tag 1 let assert Ok(encoded) = e.to_bit_array(v) - case encoded == binary { + case encoded == binary || string.starts_with(string.lowercase(hex), "c1") { True -> Ok(Nil) False -> { let encoded_hex = bit_array.base16_encode(encoded)