use std::path::{Path, PathBuf};
use prost::Message as ProstMessage;
use prototext_core::{parse_schema, render_as_bytes, render_as_text, RenderOpts};
fn repo_root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
}
fn schema_path(schema_rel: &str) -> PathBuf {
let generated = ["descriptor.pb", "knife.pb", "enum_collision.pb"];
if let Some(name) = generated
.iter()
.find(|&&n| schema_rel == format!("fixtures/schemas/{n}"))
{
return PathBuf::from(env!("OUT_DIR")).join(name);
}
repo_root().join(schema_rel)
}
fn load_schema(schema_rel: &str, message: &str) -> prototext_core::ParsedSchema {
let path = schema_path(schema_rel);
let bytes = std::fs::read(&path)
.unwrap_or_else(|e| panic!("cannot read schema {}: {e}", path.display()));
parse_schema(&bytes, message)
.unwrap_or_else(|e| panic!("cannot parse schema {schema_rel}:{message}: {e}"))
}
fn enum_schema() -> prototext_core::ParsedSchema {
load_schema("fixtures/schemas/enum_collision.pb", "EnumCollision")
}
#[test]
fn enum_known_value_renders_symbolic_name() {
let wire = vec![0x10, 0x01];
let schema = enum_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("GREEN"),
"expected symbolic name GREEN in: {text_str}"
);
assert!(
text_str.contains("Color(1)"),
"expected Color(1) in annotation: {text_str}"
);
}
#[test]
fn enum_unknown_value_renders_numeric_with_enum_unknown() {
let wire = vec![0x10, 0x63]; let schema = enum_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("99"),
"expected numeric 99 in: {text_str}"
);
assert!(
text_str.contains("ENUM_UNKNOWN"),
"expected ENUM_UNKNOWN in annotation: {text_str}"
);
}
#[test]
fn packed_enum_renders_symbolic_names() {
let wire = vec![0x2A, 0x02, 0x01, 0x02]; let schema = enum_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("GREEN"),
"expected GREEN in packed: {text_str}"
);
assert!(
text_str.contains("BLUE"),
"expected BLUE in packed: {text_str}"
);
assert!(
text_str.contains("Color(1)"),
"expected Color(1) per-element annotation: {text_str}"
);
assert!(
text_str.contains("Color(2)"),
"expected Color(2) per-element annotation: {text_str}"
);
assert!(
text_str.contains("pack_size: 2"),
"expected pack_size: 2 on first element: {text_str}"
);
}
#[test]
fn enum_annotation_roundtrips_wire() {
let wire = vec![0x10, 0x01];
let schema = enum_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(wire2, wire, "enum annotation must round-trip byte-for-byte");
}
#[test]
fn no_annotations_omits_unknown_fields() {
let wire = vec![0xb8, 0x06, 0x01]; let schema = enum_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
!text_str.contains("99"),
"unknown field should be omitted without annotations: {text_str}"
);
}
#[test]
fn enum_named_float_roundtrip_is_varint() {
let wire = vec![0x08, 0x01];
let schema = enum_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"enum named 'float' must round-trip as varint (2 bytes), not fixed32 (5 bytes)"
);
assert_eq!(
wire2.len(),
2,
"re-encoded wire must be 2 bytes (varint), not 5 (fixed32)"
);
}
fn knife_schema() -> prototext_core::ParsedSchema {
load_schema("fixtures/schemas/knife.pb", "SwissArmyKnife")
}
fn float_wire(bits: u32) -> Vec<u8> {
let mut v = vec![0xB5, 0x01]; v.extend_from_slice(&bits.to_le_bytes());
v
}
fn double_wire(bits: u64) -> Vec<u8> {
let mut v = vec![0xA9, 0x01]; v.extend_from_slice(&bits.to_le_bytes());
v
}
fn float_packed_wire(bits: &[u32]) -> Vec<u8> {
let payload: Vec<u8> = bits.iter().flat_map(|b| b.to_le_bytes()).collect();
let mut v = vec![0x92, 0x05]; v.push(payload.len() as u8);
v.extend_from_slice(&payload);
v
}
fn double_packed_wire(bits: &[u64]) -> Vec<u8> {
let payload: Vec<u8> = bits.iter().flat_map(|b| b.to_le_bytes()).collect();
let mut v = vec![0x8A, 0x05]; v.push(payload.len() as u8);
v.extend_from_slice(&payload);
v
}
const F32_CANONICAL_NAN: u32 = 0x7FC00000;
const F32_SIGNALING_NAN: u32 = 0x7F800001; const F32_SIGNED_NAN: u32 = 0xFFC00000; const F32_PAYLOAD_NAN: u32 = 0x7FC0CAFE;
const F64_CANONICAL_NAN: u64 = 0x7FF8000000000000;
const F64_SIGNALING_NAN: u64 = 0x7FF0000000000001; const F64_SIGNED_NAN: u64 = 0xFFF8000000000000; const F64_PAYLOAD_NAN: u64 = 0x7FF800000BADC0DE;
#[test]
fn float_canonical_nan_renders_bare_nan() {
let wire = float_wire(F32_CANONICAL_NAN);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("nan"),
"expected bare 'nan' for canonical NaN, got: {text_str}"
);
assert!(
!text_str.contains("nan("),
"canonical NaN must not have a modifier, got: {text_str}"
);
}
#[test]
fn float_noncanonical_nan_renders_with_modifier() {
for &bits in &[F32_SIGNALING_NAN, F32_SIGNED_NAN, F32_PAYLOAD_NAN] {
let wire = float_wire(bits);
let schema = knife_schema();
let text_no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_no_ann_str = String::from_utf8(text_no_ann).unwrap();
assert!(
text_no_ann_str.contains("nan"),
"bits 0x{:08x}: expected 'nan' in no-annotations output: {text_no_ann_str}",
bits
);
assert!(
!text_no_ann_str.contains("nan("),
"bits 0x{:08x}: non-canonical NaN must not appear as nan(…) in value: {text_no_ann_str}",
bits
);
let text_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_ann_str = String::from_utf8(text_ann).unwrap();
let expected_mod = format!("nan_bits: 0x{:08x}", bits);
assert!(
text_ann_str.contains(&expected_mod),
"bits 0x{:08x}: expected '{}' in annotation: {text_ann_str}",
bits,
expected_mod
);
}
}
#[test]
fn float_noncanonical_nan_roundtrips() {
for &bits in &[F32_SIGNALING_NAN, F32_SIGNED_NAN, F32_PAYLOAD_NAN] {
let wire = float_wire(bits);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"float NaN 0x{:08x} must round-trip bit-exact",
bits
);
}
}
#[test]
fn double_canonical_nan_renders_bare_nan() {
let wire = double_wire(F64_CANONICAL_NAN);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("nan"),
"expected bare 'nan' for canonical NaN, got: {text_str}"
);
assert!(
!text_str.contains("nan("),
"canonical NaN must not have a modifier, got: {text_str}"
);
}
#[test]
fn double_noncanonical_nan_renders_with_modifier() {
for &bits in &[F64_SIGNALING_NAN, F64_SIGNED_NAN, F64_PAYLOAD_NAN] {
let wire = double_wire(bits);
let schema = knife_schema();
let text_no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_no_ann_str = String::from_utf8(text_no_ann).unwrap();
assert!(
text_no_ann_str.contains("nan"),
"bits 0x{:016x}: expected 'nan' in no-annotations output: {text_no_ann_str}",
bits
);
assert!(
!text_no_ann_str.contains("nan("),
"bits 0x{:016x}: non-canonical NaN must not appear as nan(…) in value: {text_no_ann_str}",
bits
);
let text_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_ann_str = String::from_utf8(text_ann).unwrap();
let expected_mod = format!("nan_bits: 0x{:016x}", bits);
assert!(
text_ann_str.contains(&expected_mod),
"bits 0x{:016x}: expected '{}' in annotation: {text_ann_str}",
bits,
expected_mod
);
}
}
#[test]
fn double_noncanonical_nan_roundtrips() {
for &bits in &[F64_SIGNALING_NAN, F64_SIGNED_NAN, F64_PAYLOAD_NAN] {
let wire = double_wire(bits);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"double NaN 0x{:016x} must round-trip bit-exact",
bits
);
}
}
#[test]
fn float_packed_noncanonical_nan_renders_with_modifier() {
let wire = float_packed_wire(&[F32_CANONICAL_NAN, F32_SIGNALING_NAN, F32_PAYLOAD_NAN]);
let schema = knife_schema();
let text_no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_no_ann_str = String::from_utf8(text_no_ann).unwrap();
assert!(
text_no_ann_str.contains("nan"),
"expected bare nan in no-annotations packed output, got: {text_no_ann_str}"
);
assert!(
!text_no_ann_str.contains("nan("),
"packed NaN must not appear as nan(…) without annotations: {text_no_ann_str}"
);
let text_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_ann_str = String::from_utf8(text_ann).unwrap();
assert!(
text_ann_str.contains(&format!("nan_bits: 0x{:08x}", F32_SIGNALING_NAN)),
"expected nan_bits for signaling NaN in annotation: {text_ann_str}"
);
assert!(
text_ann_str.contains(&format!("nan_bits: 0x{:08x}", F32_PAYLOAD_NAN)),
"expected nan_bits for payload NaN in annotation: {text_ann_str}"
);
}
#[test]
fn float_packed_noncanonical_nan_roundtrips() {
let wire = float_packed_wire(&[F32_CANONICAL_NAN, F32_SIGNALING_NAN, F32_PAYLOAD_NAN]);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"packed float NaN array must round-trip bit-exact"
);
}
#[test]
fn double_packed_noncanonical_nan_renders_with_modifier() {
let wire = double_packed_wire(&[F64_CANONICAL_NAN, F64_SIGNALING_NAN, F64_PAYLOAD_NAN]);
let schema = knife_schema();
let text_no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_no_ann_str = String::from_utf8(text_no_ann).unwrap();
assert!(
text_no_ann_str.contains("nan"),
"expected bare nan in no-annotations packed output, got: {text_no_ann_str}"
);
assert!(
!text_no_ann_str.contains("nan("),
"packed double NaN must not appear as nan(…) without annotations: {text_no_ann_str}"
);
let text_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_ann_str = String::from_utf8(text_ann).unwrap();
assert!(
text_ann_str.contains(&format!("nan_bits: 0x{:016x}", F64_SIGNALING_NAN)),
"expected nan_bits for signaling NaN in annotation: {text_ann_str}"
);
assert!(
text_ann_str.contains(&format!("nan_bits: 0x{:016x}", F64_PAYLOAD_NAN)),
"expected nan_bits for payload NaN in annotation: {text_ann_str}"
);
}
#[test]
fn double_packed_noncanonical_nan_roundtrips() {
let wire = double_packed_wire(&[F64_CANONICAL_NAN, F64_SIGNALING_NAN, F64_PAYLOAD_NAN]);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"packed double NaN array must round-trip bit-exact"
);
}
#[test]
fn float_packed_all_nan_variants_roundtrip() {
let wire = float_packed_wire(&[
F32_CANONICAL_NAN,
F32_SIGNALING_NAN,
F32_SIGNED_NAN,
F32_PAYLOAD_NAN,
]);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"packed float all-NaN-variants must round-trip bit-exact"
);
}
#[test]
fn double_packed_all_nan_variants_roundtrip() {
let wire = double_packed_wire(&[
F64_CANONICAL_NAN,
F64_SIGNALING_NAN,
F64_SIGNED_NAN,
F64_PAYLOAD_NAN,
]);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"packed double all-NaN-variants must round-trip bit-exact"
);
}
#[test]
fn float_subnormals_roundtrip() {
let cases: &[u32] = &[
0x00000001, 0x007fffff, 0x80000001, 0x003fffff, ];
for &bits in cases {
let wire = float_wire(bits);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"float subnormal 0x{:08x} must round-trip bit-exact",
bits
);
}
}
#[test]
fn double_subnormals_roundtrip() {
let cases: &[u64] = &[
0x0000000000000001, 0x000fffffffffffff, 0x8000000000000001, 0x0004000000000000, ];
for &bits in cases {
let wire = double_wire(bits);
let schema = knife_schema();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire2, wire,
"double subnormal 0x{:016x} must round-trip bit-exact",
bits
);
}
}
fn strip_annotations(text: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(text.len());
for line in text.split(|&b| b == b'\n') {
if line.iter().all(|&b| b == b' ' || b == b'\t') {
continue;
}
let trimmed = line
.iter()
.position(|&b| b != b' ' && b != b'\t')
.unwrap_or(0);
if line[trimmed..].starts_with(b"#@") {
continue;
}
let stripped = if let Some(pos) = line.windows(5).rposition(|w| w == b" #@ ") {
&line[..pos]
} else {
line
};
out.extend_from_slice(stripped);
out.push(b'\n');
}
out
}
fn int32_packed_wire(values: &[i32]) -> Vec<u8> {
let payload: Vec<u8> = values
.iter()
.flat_map(|&v| {
let mut buf = [0u8; 10];
let n = encode_varint(v as u64, &mut buf);
buf[..n].to_vec()
})
.collect();
let mut v = vec![0xAA, 0x05]; v.push(payload.len() as u8);
v.extend_from_slice(&payload);
v
}
fn encode_varint(mut val: u64, buf: &mut [u8; 10]) -> usize {
let mut i = 0;
loop {
if val < 0x80 {
buf[i] = val as u8;
i += 1;
break;
}
buf[i] = (val as u8) | 0x80;
val >>= 7;
i += 1;
}
i
}
fn protoc_decode(wire: &[u8]) -> Option<Vec<u8>> {
use std::io::Write as _;
use std::process::{Command, Stdio};
let proto_path = repo_root().join("fixtures/schemas");
let mut child = match Command::new("protoc")
.args([
"--decode=SwissArmyKnife",
&format!("--proto_path={}", proto_path.display()),
"knife.proto",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("SKIP: protoc not found in PATH — skipping compatibility check");
return None;
}
Err(e) => panic!("failed to spawn protoc: {e}"),
};
child.stdin.take().unwrap().write_all(wire).unwrap();
let out = child.wait_with_output().expect("protoc wait failed");
assert!(
out.status.success(),
"protoc --decode failed: {:?}",
out.status
);
Some(out.stdout)
}
#[test]
fn packed_int32_matches_protoc_output() {
let wire = int32_packed_wire(&[1, 2, 3]);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"packed int32: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"packed int32: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
#[test]
fn packed_float_matches_protoc_output() {
let wire = float_packed_wire(&[0.0f32.to_bits(), 1.0f32.to_bits(), (-1.0f32).to_bits()]);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"packed float: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"packed float: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
#[test]
fn packed_double_matches_protoc_output() {
let wire = double_packed_wire(&[0.0f64.to_bits(), 1.0f64.to_bits(), (-1.0f64).to_bits()]);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"packed double: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"packed double: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
#[test]
fn canonical_float_nan_matches_protoc_output() {
let wire = float_wire(F32_CANONICAL_NAN);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"canonical float NaN: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"canonical float NaN: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
#[test]
fn noncanonical_float_nan_matches_protoc_output() {
for &bits in &[F32_SIGNALING_NAN, F32_SIGNED_NAN, F32_PAYLOAD_NAN] {
let wire = float_wire(bits);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"non-canonical float NaN 0x{bits:08x}: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"non-canonical float NaN 0x{bits:08x}: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
}
#[test]
fn canonical_double_nan_matches_protoc_output() {
let wire = double_wire(F64_CANONICAL_NAN);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"canonical double NaN: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"canonical double NaN: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
#[test]
fn noncanonical_double_nan_matches_protoc_output() {
for &bits in &[F64_SIGNALING_NAN, F64_SIGNED_NAN, F64_PAYLOAD_NAN] {
let wire = double_wire(bits);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"non-canonical double NaN 0x{bits:016x}: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"non-canonical double NaN 0x{bits:016x}: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
}
#[test]
fn packed_float_nan_matches_protoc_output() {
let wire = float_packed_wire(&[F32_CANONICAL_NAN, F32_SIGNALING_NAN, F32_PAYLOAD_NAN]);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"packed float NaN: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"packed float NaN: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
#[test]
fn packed_double_nan_matches_protoc_output() {
let wire = double_packed_wire(&[F64_CANONICAL_NAN, F64_SIGNALING_NAN, F64_PAYLOAD_NAN]);
let schema = knife_schema();
let Some(expected) = protoc_decode(&wire) else {
return;
};
let with_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&with_ann),
expected,
"packed double NaN: stripped annotated output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&strip_annotations(&with_ann)),
);
let no_ann = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
strip_annotations(&no_ann).as_slice(),
expected,
"packed double NaN: no-annotations output does not match protoc\n got:\n{}",
String::from_utf8_lossy(&no_ann),
);
}
fn gadget_schema() -> prototext_core::ParsedSchema {
use prost_types::{
descriptor_proto::ExtensionRange, DescriptorProto, FieldDescriptorProto,
FileDescriptorProto, FileDescriptorSet,
};
let ext_field = FieldDescriptorProto {
name: Some("blade_count".into()),
number: Some(1000),
r#type: Some(5), label: Some(1), extendee: Some(".acme.Gadget".into()),
..Default::default()
};
let gadget = DescriptorProto {
name: Some("Gadget".into()),
extension_range: vec![ExtensionRange {
start: Some(1000),
end: Some(2000),
..Default::default()
}],
..Default::default()
};
let file = FileDescriptorProto {
name: Some("gadget.proto".into()),
syntax: Some("proto2".into()),
package: Some("acme".into()),
message_type: vec![gadget],
extension: vec![ext_field],
..Default::default()
};
let fds = FileDescriptorSet { file: vec![file] };
let mut desc_bytes = Vec::new();
fds.encode(&mut desc_bytes).unwrap();
parse_schema(&desc_bytes, "acme.Gadget").unwrap()
}
#[test]
fn extension_field_renders_with_bracketed_fqn() {
let schema = gadget_schema();
let wire = vec![0xC0u8, 0x3E, 0x2A];
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("[acme.blade_count]"),
"expected bracketed FQN [acme.blade_count] in: {text_str}"
);
assert!(text_str.contains("42"), "expected value 42 in: {text_str}");
assert!(
text_str.contains("int32"),
"expected type int32 in annotation: {text_str}"
);
assert!(
text_str.contains("= 1000"),
"expected field number = 1000 in annotation: {text_str}"
);
}
#[test]
fn extension_field_roundtrip() {
let schema = gadget_schema();
let wire_orig = vec![0xC0u8, 0x3E, 0x2A];
let text = render_as_text(
&wire_orig,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire_roundtrip = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(
wire_roundtrip,
wire_orig,
"extension field round-trip failed\n text:\n{}",
String::from_utf8_lossy(&text),
);
}
fn any_schema() -> prototext_core::ParsedSchema {
use prost::Message as _;
use prost_reflect::DescriptorPool;
use prost_types::{DescriptorProto, FieldDescriptorProto, FileDescriptorProto};
use prototext_core::schema_from_pool;
let descriptor_pb =
std::fs::read(std::path::PathBuf::from(env!("OUT_DIR")).join("descriptor.pb"))
.expect("cannot read descriptor.pb from OUT_DIR");
let mut pool =
DescriptorPool::decode(descriptor_pb.as_slice()).expect("cannot decode descriptor.pb");
let any_msg = DescriptorProto {
name: Some("Any".into()),
field: vec![
FieldDescriptorProto {
name: Some("type_url".into()),
number: Some(1),
r#type: Some(9), label: Some(1), ..Default::default()
},
FieldDescriptorProto {
name: Some("value".into()),
number: Some(2),
r#type: Some(12), label: Some(1), ..Default::default()
},
],
..Default::default()
};
let any_file = FileDescriptorProto {
name: Some("google/protobuf/any.proto".into()),
syntax: Some("proto3".into()),
package: Some("google.protobuf".into()),
message_type: vec![any_msg],
..Default::default()
};
let mut any_file_bytes = Vec::new();
any_file.encode(&mut any_file_bytes).unwrap();
pool.decode_file_descriptor_proto(any_file_bytes.as_slice())
.expect("cannot add google/protobuf/any.proto to pool");
let payload_msg = DescriptorProto {
name: Some("Payload".into()),
field: vec![FieldDescriptorProto {
name: Some("label".into()),
number: Some(1),
r#type: Some(9), label: Some(1), ..Default::default()
}],
..Default::default()
};
let container_msg = DescriptorProto {
name: Some("Container".into()),
field: vec![FieldDescriptorProto {
name: Some("payload".into()),
number: Some(1),
r#type: Some(11), label: Some(1), type_name: Some(".google.protobuf.Any".into()),
..Default::default()
}],
..Default::default()
};
let acme_file = FileDescriptorProto {
name: Some("acme.proto".into()),
syntax: Some("proto2".into()),
package: Some("acme".into()),
dependency: vec!["google/protobuf/any.proto".into()],
message_type: vec![payload_msg, container_msg],
..Default::default()
};
let mut acme_bytes = Vec::new();
acme_file.encode(&mut acme_bytes).unwrap();
pool.decode_file_descriptor_proto(acme_bytes.as_slice())
.expect("cannot add acme.proto to pool");
schema_from_pool(pool, "acme.Container").expect("cannot build any_schema")
}
fn any_wire_bytes() -> Vec<u8> {
let label = b"hello";
let mut payload_bytes = vec![0x0a, label.len() as u8];
payload_bytes.extend_from_slice(label);
let type_url = b"type.googleapis.com/acme.Payload";
let mut any_bytes = Vec::new();
any_bytes.push(0x0a); any_bytes.push(type_url.len() as u8);
any_bytes.extend_from_slice(type_url);
any_bytes.push(0x12); any_bytes.push(payload_bytes.len() as u8);
any_bytes.extend_from_slice(&payload_bytes);
let mut wire = vec![0x0a, any_bytes.len() as u8];
wire.extend_from_slice(&any_bytes);
wire
}
#[test]
fn any_field_expands_type_url_and_value() {
let schema = any_schema();
let wire = any_wire_bytes();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("type_url:"),
"expected type_url line in Any expansion: {text_str}"
);
assert!(
text_str.contains("type.googleapis.com/acme.Payload"),
"expected type_url value in Any expansion: {text_str}"
);
assert!(
text_str.contains("value {"),
"expected value block in Any expansion: {text_str}"
);
assert!(
text_str.contains("hello"),
"expected Payload.label value in Any expansion: {text_str}"
);
}
#[test]
fn any_field_no_expand_renders_value_as_bytes() {
let schema = any_schema();
let wire = any_wire_bytes();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: false,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
!text_str.contains("value {"),
"value should not be an expanded block when expand_any=false: {text_str}"
);
assert!(
text_str.contains("value:"),
"value field should still be present as raw bytes: {text_str}"
);
}
#[test]
fn any_field_roundtrip() {
let schema = any_schema();
let wire = any_wire_bytes();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let wire2 = render_as_bytes(
&text,
RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
},
)
.unwrap();
assert_eq!(wire2, wire, "Any field must round-trip byte-for-byte");
}
#[test]
fn any_field_golden_annotated_output() {
let schema = any_schema();
let wire = any_wire_bytes();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let expected = concat!(
"#@ prototext: protoc\n",
"payload { #@ Any = 1\n",
" type_url: \"type.googleapis.com/acme.Payload\" #@ string = 1\n",
" value { #@ Payload = 2\n",
" label: \"hello\" #@ string = 1\n",
" }\n",
"}\n",
);
assert_eq!(
String::from_utf8(text).unwrap(),
expected,
"Any expansion golden output mismatch",
);
}
#[test]
fn any_field_unresolvable_type_url_renders_value_as_bytes() {
let schema = {
use prost::Message as _;
use prost_reflect::DescriptorPool;
use prost_types::{DescriptorProto, FieldDescriptorProto, FileDescriptorProto};
use prototext_core::schema_from_pool;
let descriptor_pb =
std::fs::read(std::path::PathBuf::from(env!("OUT_DIR")).join("descriptor.pb"))
.expect("cannot read descriptor.pb from OUT_DIR");
let mut pool =
DescriptorPool::decode(descriptor_pb.as_slice()).expect("cannot decode descriptor.pb");
let any_msg = DescriptorProto {
name: Some("Any".into()),
field: vec![
FieldDescriptorProto {
name: Some("type_url".into()),
number: Some(1),
r#type: Some(9),
label: Some(1),
..Default::default()
},
FieldDescriptorProto {
name: Some("value".into()),
number: Some(2),
r#type: Some(12),
label: Some(1),
..Default::default()
},
],
..Default::default()
};
let any_file = FileDescriptorProto {
name: Some("google/protobuf/any.proto".into()),
syntax: Some("proto3".into()),
package: Some("google.protobuf".into()),
message_type: vec![any_msg],
..Default::default()
};
let mut any_file_bytes = Vec::new();
any_file.encode(&mut any_file_bytes).unwrap();
pool.decode_file_descriptor_proto(any_file_bytes.as_slice())
.expect("cannot add google/protobuf/any.proto to pool");
let container_msg = DescriptorProto {
name: Some("Container".into()),
field: vec![FieldDescriptorProto {
name: Some("payload".into()),
number: Some(1),
r#type: Some(11),
label: Some(1),
type_name: Some(".google.protobuf.Any".into()),
..Default::default()
}],
..Default::default()
};
let acme_file = FileDescriptorProto {
name: Some("acme.proto".into()),
syntax: Some("proto2".into()),
package: Some("acme".into()),
dependency: vec!["google/protobuf/any.proto".into()],
message_type: vec![container_msg],
..Default::default()
};
let mut acme_bytes = Vec::new();
acme_file.encode(&mut acme_bytes).unwrap();
pool.decode_file_descriptor_proto(acme_bytes.as_slice())
.expect("cannot add acme.proto to pool");
schema_from_pool(pool, "acme.Container").expect("cannot build schema")
};
let wire = any_wire_bytes();
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
text_str.contains("type_url:"),
"type_url field should still be rendered: {text_str}"
);
assert!(
!text_str.contains("value {"),
"value should be raw bytes, not a nested block, when type is unresolvable: {text_str}"
);
assert!(
text_str.contains("value:"),
"value field should be rendered as raw bytes: {text_str}"
);
}
#[test]
fn any_field_value_before_type_url_renders_as_raw_len() {
let schema = any_schema();
let label = b"hello";
let mut payload_bytes = vec![0x0a, label.len() as u8];
payload_bytes.extend_from_slice(label);
let type_url = b"type.googleapis.com/acme.Payload";
let mut any_bytes = Vec::new();
any_bytes.push(0x12); any_bytes.push(payload_bytes.len() as u8);
any_bytes.extend_from_slice(&payload_bytes);
any_bytes.push(0x0a); any_bytes.push(type_url.len() as u8);
any_bytes.extend_from_slice(type_url);
let mut wire = vec![0x0a, any_bytes.len() as u8];
wire.extend_from_slice(&any_bytes);
let text = render_as_text(
&wire,
Some(&schema),
RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
},
)
.unwrap();
let text_str = String::from_utf8(text).unwrap();
assert!(
!text_str.contains("value {"),
"value block should not appear when value precedes type_url: {text_str}"
);
}
fn xorshift64(state: &mut u64) -> u64 {
*state ^= *state << 13;
*state ^= *state >> 7;
*state ^= *state << 17;
*state
}
fn random_bytes(rng: &mut u64) -> Vec<u8> {
let len = (xorshift64(rng) % 64) as usize;
(0..len).map(|_| (xorshift64(rng) & 0xff) as u8).collect()
}
#[test]
fn selftest_roundtrip() {
let n: usize = std::env::var("PROTOTEXT_SELFTEST_N")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1_000_000);
let seed: u64 = std::env::var("PROTOTEXT_SELFTEST_SEED")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0xDEAD_BEEF_CAFE_1234);
let schema = knife_schema();
let opts_enc = RenderOpts {
assume_binary: true,
include_annotations: true,
indent: 1,
expand_any: true,
};
let opts_dec = RenderOpts {
assume_binary: false,
include_annotations: false,
indent: 1,
expand_any: true,
};
let mut rng = seed;
let mut failures = 0;
for i in 0..n {
let wire = random_bytes(&mut rng);
let text = render_as_text(&wire, Some(&schema), opts_enc.clone())
.expect("render_as_text panicked");
let wire2 = render_as_bytes(&text, opts_dec.clone()).expect("render_as_bytes panicked");
if wire2 != wire {
eprintln!(
"FAIL iteration {i}: wire={} reenc={}",
wire.iter().map(|b| format!("{b:02x}")).collect::<String>(),
wire2.iter().map(|b| format!("{b:02x}")).collect::<String>(),
);
eprintln!(" text:\n{}", String::from_utf8_lossy(&text));
failures += 1;
if failures >= 5 {
panic!("selftest: too many failures, stopping early (seed={seed:#x})");
}
}
}
assert_eq!(
failures, 0,
"selftest: {failures} failures in {n} iterations (seed={seed:#x})"
);
eprintln!("selftest: {n} iterations passed (seed={seed:#x})");
}
#[test]
fn len_wire_type_on_varint_field_sets_type_mismatch_flag() {
let wire: Vec<u8> = vec![0xCA, 0x01, 0x03, 0x01, 0x02, 0x03];
let schema = knife_schema();
let msg = prototext_core::decoder::ingest_pb(&wire, &schema, false);
assert_eq!(msg.fields.len(), 1, "expected exactly one field");
let field = &msg.fields[0];
assert_eq!(
field.field_number,
Some(25),
"field number must be 25 (int32Op)"
);
assert!(
matches!(
field.content,
prototext_core::decoder::ProtoTextContent::WireBytes(_)
),
"content must be WireBytes (LEN fallback), got: {:?}",
field.content
);
assert!(
field.proto2_has_type_mismatch,
"proto2_has_type_mismatch must be true for LEN wire type on int32 field"
);
}