use crate::error::{DecodeError, Result};
use crate::primitives::{parse_json_document, I64_SAFE_MAX};
use serde_json::Value;
fn is_blob_id(value: &str) -> bool {
match value.strip_prefix("sha256:") {
Some(hex) => {
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
None => false,
}
}
pub fn serialize_blob_ref(
blob_id: &str,
byte_length: i64,
media_type: Option<&str>,
name: Option<&str>,
) -> String {
let text = |s: &str| serde_json::to_string(s).expect("a str always serializes");
let mut out = format!(
"{{\"blobId\":{},\"byteLength\":{byte_length}",
text(blob_id)
);
if let Some(media_type) = media_type {
out.push_str(&format!(",\"mediaType\":{}", text(media_type)));
}
if let Some(name) = name {
out.push_str(&format!(",\"name\":{}", text(name)));
}
out.push('}');
out
}
pub fn validate_blob_ref(raw: &str) -> Result<()> {
let value = parse_json_document(raw, "blob_ref value")?;
let obj = value
.as_object()
.ok_or_else(|| DecodeError::invalid("blob_ref value must be a JSON object (§5.9.1)"))?;
if obj
.keys()
.any(|key| !matches!(key.as_str(), "blobId" | "byteLength" | "mediaType" | "name"))
{
return Err(DecodeError::invalid("blob_ref has an unknown key (§5.9.1)"));
}
let blob_id = match obj.get("blobId").and_then(Value::as_str) {
Some(id) if is_blob_id(id) => id,
_ => {
return Err(DecodeError::invalid(
"blob_ref.blobId must be \"sha256:<64 hex>\" (§5.9.1)",
))
}
};
let byte_length = match obj.get("byteLength").and_then(Value::as_i64) {
Some(v) if (0..=I64_SAFE_MAX).contains(&v) => v,
_ => {
return Err(DecodeError::invalid(
"blob_ref.byteLength must be a non-negative integer within the safe-integer range (§5.9.1)",
))
}
};
let optional_str = |key: &str| match obj.get(key) {
None => Ok(None),
Some(Value::String(s)) => Ok(Some(s.as_str())),
Some(_) => Err(DecodeError::invalid(format!(
"blob_ref.{key} must be a string when present (§5.9.1)"
))),
};
let media_type = optional_str("mediaType")?;
let name = optional_str("name")?;
if serialize_blob_ref(blob_id, byte_length, media_type, name) != raw {
return Err(DecodeError::invalid(
"blob_ref is not in canonical form (§5.9.1)",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{serialize_blob_ref, validate_blob_ref};
fn blob_id() -> String {
format!("sha256:{}", "ab".repeat(32))
}
#[test]
fn serializes_in_the_pinned_key_order_with_json_stringify_escapes() {
let id = blob_id();
assert_eq!(
serialize_blob_ref(
&id,
1024,
Some("text/plain"),
Some("\"q\" \\ \u{8}\u{c}\n\r\t\u{1}\u{1f}\u{7f} é 😀 \u{2028}"),
),
format!(
"{{\"blobId\":\"{id}\",\"byteLength\":1024,\"mediaType\":\"text/plain\",\"name\":\"\\\"q\\\" \\\\ \\b\\f\\n\\r\\t\\u0001\\u001f\u{7f} é 😀 \u{2028}\"}}"
)
);
}
#[test]
fn accepts_canonical_text_with_and_without_the_optional_keys() {
let id = blob_id();
for raw in [
serialize_blob_ref(&id, 0, None, None),
serialize_blob_ref(&id, 9_007_199_254_740_991, None, Some("a.txt")),
serialize_blob_ref(&id, 1, Some("image/png"), Some("")),
] {
validate_blob_ref(&raw).unwrap_or_else(|e| panic!("{raw}: {}", e.detail));
}
}
#[test]
fn rejects_every_non_canonical_text() {
let id = blob_id();
let upper = format!("sha256:{}", "AB".repeat(32));
let rejected = [
(
"reordered keys",
format!(r#"{{"byteLength":1,"blobId":"{id}"}}"#),
),
(
"mediaType after name",
format!(r#"{{"blobId":"{id}","byteLength":1,"name":"a","mediaType":"b"}}"#),
),
(
"byteLength 1e2",
format!(r#"{{"blobId":"{id}","byteLength":1e2}}"#),
),
(
"byteLength 3.0",
format!(r#"{{"blobId":"{id}","byteLength":3.0}}"#),
),
(
"byteLength -0",
format!(r#"{{"blobId":"{id}","byteLength":-0}}"#),
),
(
"byteLength 01",
format!(r#"{{"blobId":"{id}","byteLength":01}}"#),
),
(
"byteLength -1",
format!(r#"{{"blobId":"{id}","byteLength":-1}}"#),
),
(
"byteLength 2^53",
format!(r#"{{"blobId":"{id}","byteLength":9007199254740992}}"#),
),
(
"whitespace",
format!(r#"{{"blobId": "{id}","byteLength":1}}"#),
),
(
"trailing newline",
format!("{{\"blobId\":\"{id}\",\"byteLength\":1}}\n"),
),
(
"duplicate key",
format!(r#"{{"blobId":"{id}","byteLength":1,"byteLength":1}}"#),
),
(
"non-canonical escape",
format!(r#"{{"blobId":"{id}","byteLength":1,"name":"\u0061"}}"#),
),
(
"escaped slash",
format!(r#"{{"blobId":"{id}","byteLength":1,"name":"a\/b"}}"#),
),
(
"unpaired surrogate name",
format!(r#"{{"blobId":"{id}","byteLength":1,"name":"\ud800"}}"#),
),
(
"U+0000 name",
format!(r#"{{"blobId":"{id}","byteLength":1,"name":"\u0000"}}"#),
),
(
"unknown key",
format!(r#"{{"blobId":"{id}","byteLength":1,"x":1}}"#),
),
(
"uppercase hex",
format!(r#"{{"blobId":"{upper}","byteLength":1}}"#),
),
("missing byteLength", format!(r#"{{"blobId":"{id}"}}"#)),
(
"numeric name",
format!(r#"{{"blobId":"{id}","byteLength":1,"name":7}}"#),
),
("array", "[]".to_owned()),
];
for (name, raw) in rejected {
let error = validate_blob_ref(&raw)
.err()
.unwrap_or_else(|| panic!("{name}: accepted {raw}"));
assert_eq!(error.code.as_str(), "sync.invalid_request", "{name}");
}
}
}