use std::path::PathBuf;
use asdf_core::Reader;
use asdf_core::layout::scan;
fn corpus_files() -> Vec<PathBuf> {
let mut roots = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
roots.push(PathBuf::from(&home).join("code/asdf-standard/reference_files/1.6.0"));
roots.push(PathBuf::from(&home).join("code/libasdf/tests/fixtures"));
}
if let Some(dir) = std::env::var_os("ASDF_STANDARD_DIR") {
roots.push(PathBuf::from(dir).join("reference_files/1.6.0"));
}
if let Some(dir) = std::env::var_os("LIBASDF_DIR") {
roots.push(PathBuf::from(dir).join("tests/fixtures"));
}
let mut out = Vec::new();
for root in roots {
let Ok(entries) = std::fs::read_dir(&root) else { continue };
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "asdf") {
out.push(path);
}
}
}
out.sort();
out.dedup();
out
}
fn exercise(bytes: &[u8]) {
let Ok(layout) = scan(bytes) else { return };
if let Some(text) = layout.tree_str(bytes) {
let _ = asdf_yaml::parse_document(text);
}
let Ok(reader) = Reader::from_bytes(bytes.to_vec()) else { return };
for index in 0..reader.block_count() {
let _ = reader.block(index);
let _ = reader.block_raw(index);
let _ = reader.block_compression(index);
let _ = reader.block_data(index);
let _ = reader.verify_block_checksum(index);
}
let _ = reader.tree();
let _ = reader.tree_inlined();
let _ = reader.has_python_checksum_bug();
let _ = asdf_core::info::render(&reader, asdf_core::info::InfoOptions::default());
let Ok(Some(doc)) = reader.tree() else { return };
exercise_arrays(&reader, &doc);
}
fn exercise_arrays(reader: &Reader, doc: &asdf_core::yaml::Document) {
use asdf_core::core::elements::{decode_all, decode_inline};
use asdf_core::core::ndarray::{Ndarray, Source};
let Some(root) = doc.root() else { return };
let mut stack = vec![root];
let mut seen = 0usize;
while let Some(id) = stack.pop() {
seen += 1;
if seen > 10_000 {
return;
}
if let Ok(nd) = Ndarray::parse(doc, id) {
let block_bytes = match nd.source {
Source::Block(index) => reader.block_data(index).ok().map(|d| d.len() as u64),
_ => None,
};
if let Ok(shape) = nd.resolved_shape(block_bytes) {
let _ = nd.len(block_bytes);
let _ = nd.nbytes(block_bytes);
match nd.source {
Source::Block(index) => {
if let Ok(data) = reader.block_data(index) {
let _ = decode_all(&nd, &shape, &data);
}
}
Source::Inline(_) => {
let _ = decode_inline(doc, &nd, &shape);
}
Source::External(_) | Source::LastBlock => {}
}
}
}
match &doc.node(doc.resolve(id)).data {
asdf_core::yaml::NodeData::Mapping { entries, .. } => {
stack.extend(entries.iter().map(|e| e.value));
}
asdf_core::yaml::NodeData::Sequence { items, .. } => {
stack.extend(items.iter().copied())
}
_ => {}
}
}
}
#[test]
fn truncation_at_every_length_never_panics() {
let files = corpus_files();
if files.is_empty() {
eprintln!("skipping: no corpus found");
return;
}
let mut checked = 0;
for path in files.iter().take(12) {
let Ok(bytes) = std::fs::read(path) else { continue };
for len in 0..bytes.len().min(1500) {
exercise(&bytes[..len]);
checked += 1;
}
let mut len = 1500;
while len < bytes.len() {
exercise(&bytes[..len]);
checked += 1;
len += 97; }
}
eprintln!("exercised {checked} truncations");
assert!(checked > 1000);
}
#[test]
fn single_byte_corruption_never_panics() {
let files = corpus_files();
if files.is_empty() {
eprintln!("skipping: no corpus found");
return;
}
let mut checked = 0;
for path in files.iter().take(8) {
let Ok(original) = std::fs::read(path) else { continue };
if original.is_empty() {
continue;
}
let mut offset = 0usize;
while offset < original.len() {
for value in [0x00u8, 0xff, 0x0a, 0xd3] {
let mut mangled = original.clone();
mangled[offset] = value;
exercise(&mangled);
checked += 1;
}
offset += 31;
}
}
eprintln!("exercised {checked} single-byte corruptions");
assert!(checked > 500);
}
#[test]
fn corrupt_block_sizes_never_panic() {
let files = corpus_files();
if files.is_empty() {
eprintln!("skipping: no corpus found");
return;
}
let magic = b"\xd3BLK";
let mut checked = 0;
for path in files.iter().take(8) {
let Ok(original) = std::fs::read(path) else { continue };
let mut position = 0usize;
while position + 4 <= original.len() {
if &original[position..position + 4] != magic {
position += 1;
continue;
}
for field_offset in [4usize, 6, 10, 14, 22, 30] {
for pattern in
[[0xffu8; 8], [0x00; 8], [0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]]
{
let mut mangled = original.clone();
let start = position + field_offset;
let end = (start + 8).min(mangled.len());
if start >= mangled.len() {
continue;
}
mangled[start..end].copy_from_slice(&pattern[..end - start]);
exercise(&mangled);
checked += 1;
}
}
position += 4;
}
}
eprintln!("exercised {checked} corrupt block headers");
assert!(checked > 0, "no block headers were found to corrupt");
}
#[test]
fn corrupt_block_indices_never_panic() {
let files = corpus_files();
if files.is_empty() {
eprintln!("skipping: no corpus found");
return;
}
let header = b"#ASDF BLOCK INDEX";
let mut checked = 0;
for path in files.iter().take(8) {
let Ok(original) = std::fs::read(path) else { continue };
let Some(position) = original.windows(header.len()).rposition(|w| w == header) else {
continue;
};
for body in [
"\n%YAML 1.1\n---\n- 999999999999\n...\n",
"\n%YAML 1.1\n---\n- -1\n...\n",
"\n%YAML 1.1\n---\n- 0\n- 0\n- 0\n...\n",
"\n%YAML 1.1\n---\n[not, integers]\n...\n",
"\n%YAML 1.1\n---\n",
"\n",
"",
] {
let mut mangled = original[..position + header.len()].to_vec();
mangled.extend_from_slice(body.as_bytes());
exercise(&mangled);
checked += 1;
}
}
eprintln!("exercised {checked} corrupt block indices");
assert!(checked > 0);
}
#[test]
fn adversarial_inputs_never_panic() {
let cases: &[(&str, Vec<u8>)] = &[
("empty", vec![]),
("header only", b"#ASDF 1.0.0\n".to_vec()),
("header no newline", b"#ASDF 1.0.0".to_vec()),
("no version", b"#ASDF \n".to_vec()),
("huge version", format!("#ASDF {}\n", "9".repeat(10_000)).into_bytes()),
("only magic", b"\xd3BLK".to_vec()),
("magic then nothing", b"#ASDF 1.0.0\n\xd3BLK".to_vec()),
("header size zero", {
let mut v = b"#ASDF 1.0.0\n".to_vec();
v.extend_from_slice(b"\xd3BLK\x00\x00");
v
}),
("header size max", {
let mut v = b"#ASDF 1.0.0\n".to_vec();
v.extend_from_slice(b"\xd3BLK\xff\xff");
v
}),
("tree never terminated", b"#ASDF 1.0.0\n%YAML 1.1\n--- !core/asdf-1.1.0\na: 1\n".to_vec()),
("deeply nested tree", {
let mut v = b"#ASDF 1.0.0\n%YAML 1.1\n--- ".to_vec();
v.extend(core::iter::repeat_n(b'[', 5000));
v.extend(core::iter::repeat_n(b']', 5000));
v.extend_from_slice(b"\n...\n");
v
}),
("many blocks claimed", {
let mut v = b"#ASDF 1.0.0\n".to_vec();
for _ in 0..100 {
v.extend_from_slice(b"\xd3BLK\x00\x30");
v.extend_from_slice(&[0u8; 48]);
}
v
}),
("invalid utf8 tree", {
let mut v = b"#ASDF 1.0.0\n%YAML 1.1\n--- ".to_vec();
v.extend_from_slice(&[0xff, 0xfe, 0xfd]);
v.extend_from_slice(b"\n...\n");
v
}),
("index without blocks", {
let mut v = b"#ASDF 1.0.0\n%YAML 1.1\n--- {}\n...\n".to_vec();
v.extend_from_slice(b"#ASDF BLOCK INDEX\n%YAML 1.1\n---\n- 12\n...\n");
v
}),
];
for (name, bytes) in cases {
exercise(bytes);
eprintln!(" ok: {name}");
}
}
#[test]
fn absurd_declared_sizes_do_not_allocate() {
let mut file = b"#ASDF 1.0.0\n%YAML 1.1\n--- {}\n...\n".to_vec();
file.extend_from_slice(b"\xd3BLK\x00\x30");
let mut header = [0u8; 48];
header[4..8].copy_from_slice(b"zlib");
header[8..16].copy_from_slice(&16u64.to_be_bytes());
header[16..24].copy_from_slice(&16u64.to_be_bytes());
header[24..32].copy_from_slice(&u64::MAX.to_be_bytes());
file.extend_from_slice(&header);
file.extend_from_slice(&[0u8; 16]);
let reader = Reader::from_bytes(file).expect("the layout itself is well formed");
assert!(
reader.block_data(0).is_err(),
"a block claiming to inflate to u64::MAX must be refused"
);
}
mod aborts {
use asdf_core::Reader;
use asdf_core::core::elements::decode_all;
use asdf_core::core::ndarray::Ndarray;
const HEADER: &[u8] =
b"#ASDF 1.0.0\n#ASDF_STANDARD 1.6.0\n%YAML 1.1\n%TAG ! tag:stsci.edu:asdf/\n\
--- !core/asdf-1.1.0\n";
fn file_with(tree: &str, block: &[u8], compression: &[u8], data_size: u64) -> Vec<u8> {
let mut f = HEADER.to_vec();
f.extend_from_slice(tree.as_bytes());
f.extend_from_slice(b"...\n");
let mut header = [0u8; 48];
header[4..4 + compression.len()].copy_from_slice(compression);
header[8..16].copy_from_slice(&(block.len() as u64).to_be_bytes());
header[16..24].copy_from_slice(&(block.len() as u64).to_be_bytes());
header[24..32].copy_from_slice(&data_size.to_be_bytes());
f.extend_from_slice(b"\xd3BLK\x00\x30");
f.extend_from_slice(&header);
f.extend_from_slice(block);
f
}
#[test]
fn an_impossible_shape_is_refused_before_it_is_allocated() {
let tree = "arr: !core/ndarray-1.1.0\n source: 0\n datatype: float64\n \
byteorder: little\n shape: [100000000, 100000000]\n";
let bytes = file_with(tree, &[0u8; 8], b"", 8);
assert!(bytes.len() < 400, "the whole attack is {} bytes", bytes.len());
let reader = Reader::from_bytes(bytes).expect("the layout is well formed");
let doc = reader.tree().unwrap().unwrap();
let node = doc.mapping_get(doc.root().unwrap(), "arr").unwrap();
let nd = Ndarray::parse(&doc, node).unwrap();
let shape = nd.resolved_shape(Some(8)).unwrap();
let data = reader.block_data(0).unwrap();
let err = decode_all(&nd, &shape, &data).expect_err("must refuse");
assert!(
format!("{err}").contains("block holds"),
"the refusal should name the mismatch, got: {err}"
);
}
#[test]
fn a_shape_whose_product_wraps_is_refused() {
let nelems: u64 = (1 << 63) + 1;
let tree = format!(
"arr: !core/ndarray-1.1.0\n source: 0\n datatype: int16\n \
byteorder: little\n shape: [{nelems}, 2]\n"
);
let reader =
Reader::from_bytes(file_with(&tree, &[0u8; 64], b"", 64)).expect("well formed");
let doc = reader.tree().unwrap().unwrap();
let node = doc.mapping_get(doc.root().unwrap(), "arr").unwrap();
let nd = Ndarray::parse(&doc, node).unwrap();
assert!(nd.len(Some(64)).is_err(), "a wrapping element count must be an error");
let shape = nd.resolved_shape(Some(64)).unwrap();
let data = reader.block_data(0).unwrap();
assert!(decode_all(&nd, &shape, &data).is_err(), "must refuse");
}
#[test]
fn a_compressed_block_may_not_inflate_past_its_declared_size() {
let raw = vec![0u8; 8 << 20];
let comp = asdf_core::compression::Compression::Zlib.compress(&raw).unwrap();
assert!(comp.len() < 16 << 10, "8 MiB of zeros should compress small");
let tree = "x: 1\n";
let reader = Reader::from_bytes(file_with(tree, &comp, b"zlib", 8)).expect("well formed");
let err = reader.block_data(0).expect_err("must refuse");
assert!(
format!("{err}").contains("declares"),
"the refusal should name the declared size, got: {err}"
);
let ok = Reader::from_bytes(file_with(tree, &comp, b"zlib", raw.len() as u64)).unwrap();
assert_eq!(ok.block_data(0).unwrap().len(), raw.len());
}
#[test]
fn a_self_referential_alias_does_not_recurse_for_ever() {
let mut f = HEADER.to_vec();
f.extend_from_slice(b"a: &a\n b: *a\n...\n");
assert!(f.len() < 128, "the whole attack is {} bytes", f.len());
let reader = Reader::from_bytes(f).expect("well formed");
let rendered = asdf_core::info::render(&reader, asdf_core::info::InfoOptions::default())
.expect("rendering a cyclic tree should succeed, not recurse");
assert!(rendered.contains("(...)"), "the cycle should be marked, not followed");
}
#[test]
fn nested_aliases_do_not_explode_the_inline_array_walk() {
use asdf_core::core::elements::decode_inline;
use asdf_core::core::ndarray::{Ndarray, Source};
let mut tree = String::from("a: &a [1,1,1,1,1,1,1,1,1,1]\n");
let mut prev = 'a';
for name in "bcdefghij".chars() {
tree.push_str(&format!("{name}: &{name} ["));
tree.push_str(&vec![format!("*{prev}"); 10].join(","));
tree.push_str("]\n");
prev = name;
}
let mut f = HEADER.to_vec();
f.extend_from_slice(tree.as_bytes());
f.extend_from_slice(b"...\n");
let reader = Reader::from_bytes(f).expect("well formed");
let doc = reader.tree().unwrap().unwrap();
let root = doc.root().unwrap();
let entries = match &doc.node(doc.resolve(root)).data {
asdf_core::yaml::NodeData::Mapping { entries, .. } => entries.clone(),
other => panic!("expected a mapping, got {other:?}"),
};
for entry in entries {
let Ok(nd) = Ndarray::parse(&doc, entry.value) else { continue };
let Ok(shape) = nd.resolved_shape(None) else { continue };
if let Source::Inline(_) = nd.source {
let _ = decode_inline(&doc, &nd, &shape);
}
}
}
#[test]
fn a_cyclic_inline_sequence_does_not_loop() {
let mut f = HEADER.to_vec();
f.extend_from_slice(b"a: &a [*a]\n...\n");
let reader = Reader::from_bytes(f).expect("well formed");
let doc = reader.tree().unwrap().unwrap();
let root = doc.root().unwrap();
let node = doc.mapping_get(root, "a").expect("a is present");
let nd = asdf_core::core::ndarray::Ndarray::parse(&doc, node).expect("parses");
let shape = nd.resolved_shape(None).expect("a shape");
assert!(shape.len() <= 64, "the shape walk followed the cycle {} deep", shape.len());
}
#[test]
fn nested_aliases_render_within_a_budget() {
let mut tree = String::from("a: &a [x,x,x,x,x,x,x,x,x,x]\n");
let mut prev = 'a';
for name in "bcdefghij".chars() {
tree.push_str(&format!("{name}: &{name} ["));
tree.push_str(&vec![format!("*{prev}"); 10].join(","));
tree.push_str("]\n");
prev = name;
}
let mut f = HEADER.to_vec();
f.extend_from_slice(tree.as_bytes());
f.extend_from_slice(b"...\n");
assert!(f.len() < 600, "the whole attack is {} bytes", f.len());
let reader = Reader::from_bytes(f).expect("well formed");
let started = std::time::Instant::now();
let rendered = asdf_core::info::render(&reader, asdf_core::info::InfoOptions::default())
.expect("rendering should complete");
let elapsed = started.elapsed();
assert!(
rendered.len() < 4 << 20,
"rendering hit the byte budget at {} bytes; the visit budget should \
have stopped it long before",
rendered.len()
);
assert!(
elapsed < core::time::Duration::from_millis(250),
"rendering a {} byte file took {elapsed:?}",
reader.bytes().len()
);
}
}