uu-objstore-r 0.2.3

Object stores with generation preconditions for immutable-snapshot data (local directory, in-memory with fault injection, Google Cloud Storage), fenced single-writer publishing, and graph-r store mirroring
Documentation
// Author: spicyzhug@gmail.com
// Copyright: lituus-io, all rights reserved
//
//! JSON API response parsing (a small, fuzzed surface).

use crate::{ContentEncoding, Error, Meta, Result, Stat};

#[derive(serde::Deserialize)]
struct ObjMeta {
    name: String,
    #[serde(default)]
    size: Option<NumStr>,
    generation: NumStr,
    #[serde(rename = "contentEncoding", default)]
    content_encoding: Option<String>,
}

/// The JSON API sends 64-bit numbers as strings; emulators sometimes as numbers.
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum NumStr {
    S(String),
    N(u64),
}

impl NumStr {
    fn get(&self) -> Option<u64> {
        match self {
            NumStr::S(s) => s.parse().ok(),
            NumStr::N(n) => Some(*n),
        }
    }
}

#[derive(serde::Deserialize)]
struct ListPage {
    #[serde(default)]
    items: Vec<ObjMeta>,
    #[serde(rename = "nextPageToken", default)]
    next_page_token: Option<String>,
}

fn to_meta(m: ObjMeta) -> Result<Meta> {
    let generation =
        m.generation.get().filter(|g| *g != 0).ok_or_else(|| Error::protocol("bad generation"))?;
    let size = match m.size {
        Some(s) => s.get().ok_or_else(|| Error::protocol("bad size"))?,
        None => 0,
    };
    Ok(Meta { key: m.name, size, generation })
}

fn to_stat(mut m: ObjMeta) -> Result<Stat> {
    let content_encoding = match m.content_encoding.take() {
        Some(t) => ContentEncoding::parse(&t)?,
        None => None,
    };
    Ok(Stat { meta: to_meta(m)?, content_encoding })
}

/// An object resource.
pub(crate) fn parse_meta(body: &[u8]) -> Result<Meta> {
    let m: ObjMeta =
        serde_json::from_slice(body).map_err(|_| Error::protocol("malformed object resource"))?;
    to_meta(m)
}

/// An object resource with its `contentEncoding`.
pub(crate) fn parse_stat(body: &[u8]) -> Result<Stat> {
    let m: ObjMeta =
        serde_json::from_slice(body).map_err(|_| Error::protocol("malformed object resource"))?;
    to_stat(m)
}

/// One page of a listing.
pub(crate) fn parse_list(body: &[u8]) -> Result<(Vec<Meta>, Option<String>)> {
    let p: ListPage =
        serde_json::from_slice(body).map_err(|_| Error::protocol("malformed listing"))?;
    let items = p.items.into_iter().map(to_meta).collect::<Result<Vec<_>>>()?;
    Ok((items, p.next_page_token))
}

/// One page of a listing, with each object's `contentEncoding`.
pub(crate) fn parse_list_stat(body: &[u8]) -> Result<(Vec<Stat>, Option<String>)> {
    let p: ListPage =
        serde_json::from_slice(body).map_err(|_| Error::protocol("malformed listing"))?;
    let items = p.items.into_iter().map(to_stat).collect::<Result<Vec<_>>>()?;
    Ok((items, p.next_page_token))
}

/// Fuzzing entry point: parse anything, never panic.
#[doc(hidden)]
pub fn fuzz(body: &[u8]) {
    let _ = parse_meta(body);
    let _ = parse_list(body);
    let _ = parse_stat(body);
    let _ = parse_list_stat(body);
    let _ = super::crc32c::from_goog_hash(std::str::from_utf8(body).ok());
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_real_shapes() {
        let m =
            parse_meta(br#"{"name":"a/b","size":"12","generation":"1695000000123456"}"#).unwrap();
        assert_eq!((m.key.as_str(), m.size, m.generation), ("a/b", 12, 1_695_000_000_123_456));
        let m = parse_meta(br#"{"name":"x","size":3,"generation":7}"#).unwrap();
        assert_eq!((m.size, m.generation), (3, 7));
        let (items, next) = parse_list(
            br#"{"items":[{"name":"a","size":"1","generation":"2"}],"nextPageToken":"t"}"#,
        )
        .unwrap();
        assert_eq!((items.len(), next.as_deref()), (1, Some("t")));
        let (items, next) = parse_list(b"{}").unwrap();
        assert!(items.is_empty() && next.is_none());
        let s = parse_stat(br#"{"name":"a","size":"9","generation":"3","contentEncoding":"gzip"}"#)
            .unwrap();
        assert_eq!(s.content_encoding, Some(ContentEncoding::Gzip));
        let s = parse_stat(br#"{"name":"a","generation":"3"}"#).unwrap();
        assert_eq!((s.content_encoding, s.meta.size), (None, 0));
        let (items, _) = parse_list_stat(
            br#"{"items":[{"name":"a","generation":"2","contentEncoding":"identity"},
                          {"name":"b","generation":"4","contentEncoding":"gzip"}]}"#,
        )
        .unwrap();
        assert_eq!(items[0].content_encoding, None);
        assert_eq!(items[1].content_encoding, Some(ContentEncoding::Gzip));
        assert!(matches!(
            parse_stat(br#"{"name":"a","generation":"3","contentEncoding":"br"}"#),
            Err(Error::UnsupportedEncoding { .. })
        ));
        for bad in [
            &b""[..],
            b"null",
            b"[]",
            br#"{"name":"a","generation":"0"}"#,
            br#"{"name":"a","generation":"x"}"#,
        ] {
            assert!(parse_meta(bad).is_err());
        }
    }
}