
JSON without jsonlite: parsing, encoding and exact serialisation
Source:vignettes/json-and-serialisation.Rmd
json-and-serialisation.RmdManifests, provenance records and signed documents are JSON. The package parses and writes JSON natively, in R with no parser dependency, with jsonlite’s conventions, so a capsule has no dependency on another package’s parser and the parser can be held to the same standard as the cryptography.
Encoding R objects
bricklayer_json_to_json() follows jsonlite’s
toJSON() conventions (data frames as rows,
auto_unbox, pretty, na
handling):
bricklayer_json_to_json(list(a = 1:3, b = "x"), auto_unbox = TRUE)
#> {"a":[1,2,3],"b":"x"}
bricklayer_json_to_json(data.frame(id = 1:2, v = c(1.5, NA)), pretty = TRUE)
#> [
#> {
#> "id": 1,
#> "v": 1.5
#> },
#> {
#> "id": 2
#> }
#> ]Parsing JSON
bricklayer_json_from_json() follows
fromJSON(), with the simplification options:
txt <- '{"id":[1,2,3],"name":["a","b","c"],"ok":true}'
str(bricklayer_json_from_json(txt))
#> List of 3
#> $ id : int [1:3] 1 2 3
#> $ name: chr [1:3] "a" "b" "c"
#> $ ok : logi TRUE
str(bricklayer_json_from_json(txt, simplifyVector = FALSE))
#> List of 3
#> $ id :List of 3
#> ..$ : int 1
#> ..$ : int 2
#> ..$ : int 3
#> $ name:List of 3
#> ..$ : chr "a"
#> ..$ : chr "b"
#> ..$ : chr "c"
#> $ ok : logi TRUEA repeated key is an error
JSON permits duplicate keys and most parsers silently keep the last one, which has been the root of several real vulnerabilities (two parsers disagreeing about which value wins). Here a duplicate key is an error unless you ask to keep it:
try(bricklayer_json_from_json('{"a":1,"a":2}'))
#> Error : duplicate key "a" at character 14
bricklayer_json_from_json('{"a":1,"a":2}', duplicate_keys = "keep")
#> $a
#> [1] 1
#>
#> $a
#> [1] 2Big integers
Numbers beyond the exact range of a double are flagged, and can be returned as text so no digit is lost:
bricklayer_json_from_json('{"id": 9007199254740993}', bigint_as_char = TRUE)
#> $id
#> [1] "9007199254740993"Exact serialisation: types and attributes included
Data JSON loses R’s structure: a factor becomes strings, a matrix
becomes nested arrays, a Date becomes text.
bricklayer_json_serialize() writes an object with
its type and attributes, so the round trip is
identical():
f <- factor(c("b", "a", "b"), levels = c("a", "b", "c"))
back <- bricklayer_json_unserialize(bricklayer_json_serialize(f))
identical(back, f)
#> [1] TRUE
m <- matrix(1:6, nrow = 2)
identical(bricklayer_json_unserialize(bricklayer_json_serialize(m)), m)
#> [1] TRUE
x <- list(a = 1:3, b = list(c = "x", d = NULL), e = TRUE)
identical(bricklayer_json_unserialize(bricklayer_json_serialize(x)), x)
#> [1] TRUEPlain data JSON does not keep either, which is the trade-off:
bricklayer_json_to_json(f)
#> ["b","a","b"]Doubles are rounded to digits by default;
I(17) keeps them exact to the last bit:
bricklayer_json_serialize(1 / 3)
#> {"type":"double","attributes":{},"value":[0.33333333]}
exact <- bricklayer_json_serialize(1 / 3, digits = I(17))
identical(bricklayer_json_unserialize(exact), 1 / 3)
#> [1] TRUEThe serialised form is text, so it can be pinned like any other:
core_sha256(bricklayer_json_serialize(m))
#> [1] "8ad8c97422a65fd2c22eb76f9ef58957b1456bdb941dac86dbe0cee57be7ac46"Compressed, base64 JSON
For embedding a table inside another document (a manifest field, a
URL, a signed record), json_gzip_encode() gzips the JSON
and base64-encodes the bytes:
x <- list(rows = data.frame(id = 1:50, value = round(stats::runif(50), 3)))
enc <- json_gzip_encode(x)
substring(enc, 1, 40)
#> [1] "H4sIAAAAAAAA/23UwUoEMQzG8XfpeZE0SZtmXkU8"
c(json = nchar(bricklayer_json_to_json(x)), gzip_b64 = nchar(enc))
#> json gzip_b64
#> 1193 393
identical(json_gzip_decode(enc)$rows$id, 1:50)
#> [1] TRUEBase64 and base64url are available on their own:
bricklayer_json_base64_enc(charToRaw("manifest"))
#> [1] "bWFuaWZlc3Q="
rawToChar(bricklayer_json_base64_dec("bWFuaWZlc3Q="))
#> [1] "manifest"Why the parser is held to a security standard
Every network body, cached manifest and signature file the package reads goes through this parser. It was reviewed for the classic parser failures and each is tested: duplicate keys (above), string assembly that was quadratic (250,000 escapes once took 16 minutes; it is linear now), nesting depth, numbers that are not numbers, bytes that are not UTF-8, and a body that is a bare URL, which is “not JSON”, never a second fetch. The parser and the encoder are R code (see below), so they run under R’s own memory management rather than a sanitizer; the test suite carries the corpus of malformed inputs the review produced.