extensions/native-sink.md: what a native host's sink does for a stream it plays through its platform's player. Verification (§4): a media playlist against its playlist receipt and the chain of its live revisions, an object against the content hash its URL states, the host told per object whether it may hand the bytes to the player. Observations and the log (§5), the rule that ends a session nobody can see or attends to (§6), the energy a native host states (§7: the platform's decoder is outside every counter an application reads, so the sink decode is the unmetered marker there), the reasons (§8), and the C interface every native integration shares (§9). The corpus gains a live revision that rebinds an alias, a parent served under another name, and page-parity: wai-audit/web/native-sink.test.mjs replays it through the page's integration of stream-audit, which signs the same bundle byte for byte. Co-Authored-By: Claude Opus 5.5 <[email protected]>
205 lines
10 KiB
Rust
205 lines
10 KiB
Rust
//! The native sink (extensions/native-sink.md): the committed corpus
|
|
//! `live-conformance/native-sink/vectors.json` regenerates byte for byte from its fixed seeds and
|
|
//! is the only file there besides its README; every call in it gets the answer it states; every
|
|
//! reason §8 names is reached by a vector; and the C ABI (`ffi_sink`) gives the answers the Rust
|
|
//! module gives, through the symbols a C host links.
|
|
#![cfg(feature = "native_sink")]
|
|
|
|
use std::ffi::CString;
|
|
use std::path::PathBuf;
|
|
|
|
use serde_json::{json, Value};
|
|
use wai::ffi::{wai_buffer_free, WaiBuffer, WAI_OK};
|
|
use wai::ffi_sink::*;
|
|
use wai::native_sink::corpus;
|
|
|
|
fn wai_dir() -> PathBuf {
|
|
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
|
}
|
|
|
|
fn committed() -> Vec<u8> {
|
|
std::fs::read(wai_dir().join("live-conformance/native-sink/vectors.json")).unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn the_committed_corpus_regenerates_from_its_seeds() {
|
|
assert!(committed() == corpus::bytes(&corpus::generate()), "live-conformance/native-sink/vectors.json differs from `wai_native_sink_vectors gen`");
|
|
let mut files: Vec<String> = std::fs::read_dir(wai_dir().join("live-conformance/native-sink")).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
|
files.sort();
|
|
assert_eq!(files, ["README.md", "vectors.json"]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_call_gets_its_stated_answer() {
|
|
let doc: Value = serde_json::from_slice(&committed()).unwrap();
|
|
corpus::verify(&doc).unwrap();
|
|
}
|
|
|
|
/// Every reason of §8 is reached by a vector, and the extension's table lists exactly those.
|
|
#[test]
|
|
fn every_reason_refuses_a_vector() {
|
|
let doc: Value = serde_json::from_slice(&committed()).unwrap();
|
|
let text = std::fs::read_to_string(wai_dir().join("extensions/native-sink.md")).unwrap();
|
|
let mut reached = std::collections::BTreeSet::new();
|
|
for c in doc["cases"].as_array().unwrap() {
|
|
for call in c["calls"].as_array().unwrap() {
|
|
for k in ["refused", "error"] {
|
|
if let Some(r) = call["answer"][k].as_str() {
|
|
reached.insert(r.to_string());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for c in doc["configs"].as_array().unwrap() {
|
|
reached.insert(c["refused"].as_str().unwrap().to_string());
|
|
}
|
|
let table: std::collections::BTreeSet<String> = text
|
|
.split("## 8. Reasons")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split("\n## ")
|
|
.next()
|
|
.unwrap()
|
|
.lines()
|
|
.filter_map(|l| l.strip_prefix("| `").and_then(|r| r.split('`').next()).map(str::to_string))
|
|
.collect();
|
|
assert_eq!(reached, table, "the reasons the corpus reaches and §8's table");
|
|
}
|
|
|
|
fn take(b: WaiBuffer) -> Vec<u8> {
|
|
let v = unsafe { std::slice::from_raw_parts(b.data, b.len) }.to_vec();
|
|
unsafe { wai_buffer_free(b) };
|
|
v
|
|
}
|
|
|
|
fn empty() -> WaiBuffer {
|
|
WaiBuffer { data: std::ptr::null_mut(), len: 0, cap: 0 }
|
|
}
|
|
|
|
/// Every case, run through the C ABI, gets the answers the corpus states for it.
|
|
#[test]
|
|
fn the_c_abi_gives_the_corpus_answers() {
|
|
assert_eq!(wai_sink_abi_version(), WAI_SINK_ABI_VERSION);
|
|
let doc: Value = serde_json::from_slice(&committed()).unwrap();
|
|
let obj = |n: &Value| -> Vec<u8> { n.as_str().map(|n| wai::stream_audit::unhex(doc["objects"][n].as_str().unwrap()).unwrap()).unwrap_or_default() };
|
|
for case in doc["cases"].as_array().unwrap() {
|
|
let cfg = CString::new(case["config"].to_string()).unwrap();
|
|
let seed = wai::stream_audit::unhex(case["seed"].as_str().unwrap()).unwrap();
|
|
let mut s: *mut WaiSink = std::ptr::null_mut();
|
|
assert_eq!(unsafe { wai_sink_open(cfg.as_ptr(), seed.as_ptr(), 32, case["start_us"].as_u64().unwrap(), &mut s) }, WAI_OK);
|
|
for call in case["calls"].as_array().unwrap() {
|
|
let d = &call["do"];
|
|
let want = &call["answer"];
|
|
let mut out = empty();
|
|
let got: Value = match d["call"].as_str().unwrap() {
|
|
"playlist" => {
|
|
let url = CString::new(d["url"].as_str().unwrap()).unwrap();
|
|
let b = obj(&d["body"]);
|
|
let r = obj(&d["receipt"]);
|
|
let rp = if d["receipt"].is_null() { std::ptr::null() } else { r.as_ptr() };
|
|
let st = unsafe { wai_sink_playlist(s, url.as_ptr(), b.as_ptr(), b.len(), rp, r.len(), &mut out) };
|
|
if st == WAI_OK { serde_json::from_slice(&take(out)).unwrap() } else { json!({"error": last_reason()}) }
|
|
}
|
|
"parent" => {
|
|
let url = CString::new(d["url"].as_str().unwrap()).unwrap();
|
|
let name = CString::new(d["name"].as_str().unwrap()).unwrap();
|
|
let b = obj(&d["body"]);
|
|
let bp = if d["body"].is_null() { std::ptr::null() } else { b.as_ptr() };
|
|
let st = unsafe { wai_sink_playlist_parent(s, url.as_ptr(), name.as_ptr(), bp, b.len()) };
|
|
if st == WAI_OK { json!({"ok": true}) } else { json!({"error": last_reason()}) }
|
|
}
|
|
"object" => {
|
|
let meta = CString::new(d["meta"].to_string()).unwrap();
|
|
let b = obj(&d["body"]);
|
|
let st = unsafe { wai_sink_object(s, meta.as_ptr(), b.as_ptr(), b.len(), &mut out) };
|
|
if st == WAI_OK { serde_json::from_slice(&take(out)).unwrap() } else { json!({"error": last_reason()}) }
|
|
}
|
|
"event" => {
|
|
let n = CString::new(d["name"].as_str().unwrap()).unwrap();
|
|
let st = unsafe { wai_sink_event(s, n.as_ptr(), d["t_us"].as_u64().unwrap()) };
|
|
if st == WAI_OK { json!({"ok": true}) } else { json!({"error": last_reason()}) }
|
|
}
|
|
"observe" => {
|
|
let o = CString::new(d["obs"].to_string()).unwrap();
|
|
let st = unsafe { wai_sink_observe(s, o.as_ptr(), &mut out) };
|
|
if st == WAI_OK { serde_json::from_slice(&take(out)).unwrap() } else { json!({"error": last_reason()}) }
|
|
}
|
|
"energy" => {
|
|
let e = CString::new(d["entry"].to_string()).unwrap();
|
|
let st = unsafe { wai_sink_energy(s, e.as_ptr()) };
|
|
if st == WAI_OK { json!({"ok": true}) } else { json!({"error": last_reason()}) }
|
|
}
|
|
"attach" => {
|
|
let n = CString::new(d["name"].as_str().unwrap()).unwrap();
|
|
let b = obj(&d["body"]);
|
|
let st = unsafe { wai_sink_attach(s, n.as_ptr(), b.as_ptr(), b.len()) };
|
|
if st == WAI_OK { json!({"ok": true}) } else { json!({"error": last_reason()}) }
|
|
}
|
|
"cmcd" => {
|
|
let st = unsafe { wai_sink_take_cmcd(s, &mut out) };
|
|
if st == WAI_OK { json!({"cmcd": String::from_utf8(take(out)).unwrap()}) } else { json!({"error": last_reason()}) }
|
|
}
|
|
"end" => {
|
|
let n = CString::new(d["ended"].as_str().unwrap()).unwrap();
|
|
let st = unsafe { wai_sink_end(s, n.as_ptr(), d["t_us"].as_u64().unwrap(), &mut out) };
|
|
if st == WAI_OK { json!({"bundle": String::from_utf8(take(out)).unwrap()}) } else { json!({"error": last_reason()}) }
|
|
}
|
|
other => panic!("{other}"),
|
|
};
|
|
assert_eq!(&got, want, "{}: {d}", case["name"]);
|
|
}
|
|
unsafe { wai_sink_free(s) };
|
|
}
|
|
}
|
|
|
|
fn last_reason() -> String {
|
|
let p = wai::ffi::wai_last_error();
|
|
let s = unsafe { std::ffi::CStr::from_ptr(p) }.to_string_lossy().into_owned();
|
|
s.split(':').next().unwrap().to_string()
|
|
}
|
|
|
|
/// A session with no seed has a fresh key: two sessions never share one (stream-audit §3.2).
|
|
#[test]
|
|
fn a_session_without_a_seed_has_a_fresh_key() {
|
|
let cfg = CString::new(json!({"channel": "film-library", "owner": "00".repeat(32)}).to_string()).unwrap();
|
|
let mut players = std::collections::BTreeSet::new();
|
|
for _ in 0..8 {
|
|
let mut s: *mut WaiSink = std::ptr::null_mut();
|
|
assert_eq!(unsafe { wai_sink_open(cfg.as_ptr(), std::ptr::null(), 0, 1, &mut s) }, WAI_OK);
|
|
let mut out = empty();
|
|
assert_eq!(unsafe { wai_sink_info(s, &mut out) }, WAI_OK);
|
|
let info: Value = serde_json::from_slice(&take(out)).unwrap();
|
|
players.insert(info["player"].as_str().unwrap().to_string());
|
|
unsafe { wai_sink_free(s) };
|
|
}
|
|
assert_eq!(players.len(), 8);
|
|
}
|
|
|
|
/// Bad inputs return the sink's status codes, never a panic across the boundary.
|
|
#[test]
|
|
fn the_c_abi_refuses_bad_inputs_with_its_codes() {
|
|
let mut s: *mut WaiSink = std::ptr::null_mut();
|
|
let bad = CString::new("{\"channel\": 1}").unwrap();
|
|
assert_eq!(unsafe { wai_sink_open(bad.as_ptr(), std::ptr::null(), 0, 1, &mut s) }, WAI_ERR_SINK_INPUT);
|
|
assert!(s.is_null());
|
|
let not_json = CString::new("{").unwrap();
|
|
assert_eq!(unsafe { wai_sink_open(not_json.as_ptr(), std::ptr::null(), 0, 1, &mut s) }, WAI_ERR_SINK_INPUT);
|
|
let cfg = CString::new(json!({"channel": "film-library", "owner": "00".repeat(32)}).to_string()).unwrap();
|
|
assert_eq!(unsafe { wai_sink_open(cfg.as_ptr(), [1u8; 31].as_ptr(), 31, 1, &mut s) }, wai::ffi::WAI_ERR_INVALID_ARG);
|
|
assert_eq!(unsafe { wai_sink_open(cfg.as_ptr(), std::ptr::null(), 0, 1, &mut s) }, WAI_OK);
|
|
let mut out = empty();
|
|
let e = CString::new("ended").unwrap();
|
|
assert_eq!(unsafe { wai_sink_end(std::ptr::null(), e.as_ptr(), 2, &mut out) }, wai::ffi::WAI_ERR_INVALID_ARG);
|
|
let u = CString::new("https://a.example/x").unwrap();
|
|
assert_eq!(unsafe { wai_sink_playlist(s, u.as_ptr(), std::ptr::null(), 4, std::ptr::null(), 0, &mut out) }, wai::ffi::WAI_ERR_INVALID_ARG);
|
|
let meta = CString::new(json!({"url": "https://a.example/x", "t_us": 2}).to_string()).unwrap();
|
|
assert_eq!(unsafe { wai_sink_object(s, meta.as_ptr(), b"x".as_ptr(), 1, &mut out) }, WAI_OK);
|
|
unsafe { wai_buffer_free(out) };
|
|
let mut out = empty();
|
|
assert_eq!(unsafe { wai_sink_end(s, e.as_ptr(), 3, &mut out) }, WAI_OK);
|
|
unsafe { wai_buffer_free(out) };
|
|
let mut out = empty();
|
|
assert_eq!(unsafe { wai_sink_end(s, e.as_ptr(), 4, &mut out) }, WAI_ERR_SINK_ENDED);
|
|
assert!(out.data.is_null());
|
|
unsafe { wai_sink_free(s) };
|
|
}
|