//! The `wai http` commands run as a user runs them (`--features http --test cli_http`): //! //! - `wai http publish` writes `.wai`, its head proof and its ETag sidecar, named by //! the content hash, and refuses an envelope with a byte after it (§5.1); //! - `wai http codecs` prints a sequence's union and floor (§4.2); //! - `wai http origin` serves an object with §5.3's headers, byte ranges and `If-Range`, //! and `wai http bench` fetches it through the byte-range procedure (§7) and writes a //! record with the pick, the bytes by category and energy stated as not measured; //! - the origin reads at most 16 KiB of a request head, gives a whole head one deadline //! from the accept and a whole connection one lifetime, caps the connections it serves //! at once (and those of one peer only when asked), percent-decodes `/__hits` as bytes, //! and refuses a path segment with `\`, `:`, NUL or a control byte. Every origin a test //! starts is stopped when the test ends, whether or not it passed. use std::io::{BufRead, BufReader, Read, Write}; use std::net::TcpStream; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; use wai::http_binding::head_proof::HeadProof; use wai::http_binding::{content_hash, hex}; fn wai(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).args(args).output().expect("run wai") } fn corpus() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../http-conformance") } fn object(id: &str) -> PathBuf { let listing: serde_json::Value = serde_json::from_slice(&std::fs::read(corpus().join("objects.json")).unwrap()).unwrap(); corpus().join(listing[id]["path"].as_str().unwrap()) } fn scratch(name: &str) -> PathBuf { let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cli_http").join(name); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn publish_names_by_content_hash() { let out = scratch("publish"); let src = object("O3"); let run = wai(&["http", "publish", src.to_str().unwrap(), "--out", out.to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let env = std::fs::read(&src).unwrap(); let cid = hex(&content_hash(&env)); assert_eq!(std::fs::read(out.join(format!("{cid}.wai"))).unwrap(), env); assert_eq!(std::fs::read(out.join(format!("{cid}.wai.hp"))).unwrap(), HeadProof::build(&env).unwrap().to_bytes()); assert_eq!(std::fs::read_to_string(out.join(format!("{cid}.wai.etag"))).unwrap(), format!("\"{cid}\"\n")); let line = String::from_utf8_lossy(&run.stdout); assert!(line.starts_with(&cid) && line.contains(&format!("n={}", env.len())) && line.contains("wai2.video.av1+meta.hdr10plus"), "{line}"); // One byte after the envelope: not an object. let bad = corpus().join("bad/O6.wai"); let run = wai(&["http", "publish", bad.to_str().unwrap(), "--out", out.to_str().unwrap()]); assert!(!run.status.success()); assert!(String::from_utf8_lossy(&run.stderr).contains("object_length_mismatch")); } #[test] fn codecs_prints_union_and_floor() { let run = wai(&["http", "codecs", object("O9a").to_str().unwrap(), object("O9b").to_str().unwrap(), object("O9c").to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); assert_eq!(String::from_utf8_lossy(&run.stdout), "union\twai2.video.av1|video.int_hyper\nfloor\twai2.video.av1\n"); } /// A running `wai http origin`, stopped when dropped, so a failing assertion never leaves /// it running. struct Origin { child: std::process::Child, port: u16, } impl Origin { fn start(root: &Path, extra: &[&str]) -> Origin { let mut child = Command::new(env!("CARGO_BIN_EXE_wai")) .args(["http", "origin", "--root", root.to_str().unwrap(), "--port", "0"]) .args(extra) .stdout(Stdio::piped()) .spawn() .unwrap(); let mut first = String::new(); BufReader::new(child.stdout.take().unwrap()).read_line(&mut first).unwrap(); let port: u16 = first.trim().rsplit(':').next().unwrap().trim_end_matches('/').parse().unwrap(); Origin { child, port } } } impl Drop for Origin { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } /// Send raw request bytes; return what the origin answered before closing. fn raw(port: u16, request: &[u8]) -> String { let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(10))).unwrap(); s.write_all(request).unwrap(); let mut out = Vec::new(); let _ = s.read_to_end(&mut out); String::from_utf8_lossy(&out).into_owned() } /// One request to the origin: status line and headers, and the body. fn get(port: u16, path: &str, extra: &[(&str, &str)]) -> (String, Vec) { let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); let mut req = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n"); for (k, v) in extra { req.push_str(&format!("{k}: {v}\r\n")); } req.push_str("\r\n"); s.write_all(req.as_bytes()).unwrap(); let mut raw = Vec::new(); s.read_to_end(&mut raw).unwrap(); let end = raw.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; (String::from_utf8_lossy(&raw[..end]).into_owned(), raw[end..].to_vec()) } #[test] fn origin_serves_and_bench_fetches() { let root = scratch("origin"); let src = object("OD"); let env = std::fs::read(&src).unwrap(); let pubd = wai(&["http", "publish", src.to_str().unwrap(), "--out", root.to_str().unwrap()]); assert!(pubd.status.success()); let cid = hex(&content_hash(&env)); let origin = Origin::start(&root, &[]); let port = origin.port; let path = format!("/{cid}.wai"); let (h, body) = get(port, &path, &[]); assert!(h.starts_with("HTTP/1.1 200") && body == env, "{h}"); for want in ["Cache-Control: public, max-age=31536000, immutable, no-transform", &format!("ETag: \"{cid}\""), "Accept-Ranges: bytes", "Access-Control-Allow-Origin: *"] { assert!(h.contains(want), "{want} missing from {h}"); } assert!(!h.contains("Vary") && !h.contains("Content-Encoding"), "{h}"); let (h, body) = get(port, &path, &[("Range", "bytes=1009-3593")]); assert!(h.starts_with("HTTP/1.1 206") && h.contains("Content-Range: bytes 1009-3593/53794") && body == env[1009..3594], "{h}"); let (h, _) = get(port, &path, &[("Range", "bytes=1009-3593"), ("If-Range", "\"not-this\"")]); assert!(h.starts_with("HTTP/1.1 200"), "{h}"); let (h, _) = get(port, &path, &[("Range", "bytes=53794-")]); assert!(h.starts_with("HTTP/1.1 416") && h.contains("Content-Range: bytes */53794"), "{h}"); let rec = root.join("bench.json"); let url = format!("http://127.0.0.1:{port}{path}#h=1009&n=53794"); let run = wai(&["http", "bench", "--url", &url, "--runs", "3", "--advertise", "wai.video.av1", "--out", rec.to_str().unwrap()]); drop(origin); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let r: serde_json::Value = serde_json::from_slice(&std::fs::read(&rec).unwrap()).unwrap(); // Appendix D's plan 1: the head, the proof and `low`'s rest. assert_eq!(r["pick"], 0); assert_eq!(r["body_bytes_media"]["median"], 1009.0 + 2585.0); assert_eq!(r["body_bytes_proof"]["median"], 245.0); assert_eq!(r["requests"], 3); assert_eq!(r["energy"], "omitted: not measured"); assert_eq!(r["envelope_len"], 53_794); } /// The origin reads at most 16 KiB of a request head: a 400 MiB header is answered 431 /// and the connection closed long before it is sent. #[test] fn origin_refuses_an_oversized_head() { let root = scratch("origin-big"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &[]); let mut s = TcpStream::connect(("127.0.0.1", origin.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(20))).unwrap(); let mut w = s.try_clone().unwrap(); let t0 = std::time::Instant::now(); let writer = std::thread::spawn(move || { let _ = w.write_all(b"GET /x.json HTTP/1.1\r\nHost: a\r\nX-Fill: "); let chunk = vec![b'a'; 1 << 20]; let mut sent = 0usize; while sent < 400 << 20 { if w.write_all(&chunk).is_err() { break; } sent += chunk.len(); } sent }); let mut head = [0u8; 64]; let n = s.read(&mut head).unwrap_or(0); let answer = String::from_utf8_lossy(&head[..n]).into_owned(); drop(s); let sent = writer.join().unwrap(); assert!(answer.starts_with("HTTP/1.1 431"), "{answer:?}"); assert!(sent < 400 << 20, "the origin read the whole 400 MiB"); assert!(t0.elapsed() < std::time::Duration::from_secs(30)); // And it still serves. assert!(raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n").starts_with("HTTP/1.1 200")); } /// `/__hits?path=` is percent-decoded as bytes: an escape before a non-ASCII character, or /// an invalid UTF-8 sequence, is answered, not a panic. #[test] fn origin_decodes_hits_as_bytes() { let root = scratch("origin-hits"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &["--count-hits"]); for q in ["%aé", "%ff%fe", "%", "%4", "%e9%"] { let r = raw(origin.port, format!("GET /__hits?path={q} HTTP/1.1\r\nHost: a\r\n\r\n").as_bytes()); assert!(r.starts_with("HTTP/1.1 200"), "{q}: {r:?}"); } let _ = raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n"); let r = raw(origin.port, b"GET /__hits?path=%2Fx.json HTTP/1.1\r\nHost: a\r\n\r\n"); assert!(r.ends_with("\r\n\r\n1"), "{r:?}"); } /// A path segment holding `\`, `:`, NUL or a control byte, or `.`/`..`, names nothing: /// 400. #[test] fn origin_refuses_backslash_drive_and_nul_paths() { let root = scratch("origin-paths"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &[]); for path in [&b"/..\\..\\etc\\passwd"[..], b"/C:/Windows/win.ini", b"/x.json:stream", b"/a\x00b", b"/a\x01b", b"/./x.json", b"/../x.json", b"//x.json", b"/x.json/"] { let mut req = b"GET ".to_vec(); req.extend_from_slice(path); req.extend_from_slice(b" HTTP/1.1\r\nHost: a\r\n\r\n"); let r = raw(origin.port, &req); assert!(r.starts_with("HTTP/1.1 400"), "{}: {r:?}", String::from_utf8_lossy(path)); } assert!(raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n").starts_with("HTTP/1.1 200")); } /// At most `--max-connections` at once (one more is answered 503), and a connection that /// sends nothing is answered 408 and closed `--read-timeout-ms` after its accept. #[test] fn origin_caps_connections_and_times_out() { let root = scratch("origin-cap"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &["--max-connections", "2", "--read-timeout-ms", "700"]); let idle: Vec = (0..2).map(|_| TcpStream::connect(("127.0.0.1", origin.port)).unwrap()).collect(); std::thread::sleep(std::time::Duration::from_millis(200)); let r = raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n"); assert!(r.starts_with("HTTP/1.1 503"), "{r:?}"); let t0 = std::time::Instant::now(); for mut c in idle { // Told 408, and closed. c.set_read_timeout(Some(std::time::Duration::from_secs(10))).unwrap(); let mut out = Vec::new(); let _ = c.read_to_end(&mut out); assert!(out.starts_with(b"HTTP/1.1 408"), "{:?}", String::from_utf8_lossy(&out)); } assert!(t0.elapsed() < std::time::Duration::from_secs(5)); assert!(raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n").starts_with("HTTP/1.1 200")); } /// N1: a whole request head has one deadline from the accept, so connections that send a /// byte at a time cannot hold the connection cap: a GET succeeds within about one head /// deadline while they keep trickling. #[test] fn origin_survives_trickling_heads() { let root = scratch("origin-trickle"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &["--max-connections", "4", "--read-timeout-ms", "1000"]); let port = origin.port; let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let tricklers: Vec<_> = (0..4).map(|_| { let stop = stop.clone(); std::thread::spawn(move || { let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap(); let mut sent = 0; // One byte every 100 ms, never ending the head, until the origin closes. for b in b"GET /x.json HTTP/1.1\r\nX-Slow: ".iter().chain(std::iter::repeat(&b'a')) { if stop.load(std::sync::atomic::Ordering::SeqCst) || s.write_all(&[*b]).is_err() { break; } sent += 1; std::thread::sleep(std::time::Duration::from_millis(100)); } sent }) }).collect(); std::thread::sleep(std::time::Duration::from_millis(200)); let t0 = std::time::Instant::now(); let mut answer = String::new(); while t0.elapsed() < std::time::Duration::from_secs(5) { answer = raw(port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n"); if answer.starts_with("HTTP/1.1 200") { break; } std::thread::sleep(std::time::Duration::from_millis(50)); } let waited = t0.elapsed(); stop.store(true, std::sync::atomic::Ordering::SeqCst); for t in tricklers { t.join().unwrap(); } assert!(answer.starts_with("HTTP/1.1 200"), "{answer:?}"); assert!(waited < std::time::Duration::from_millis(2500), "the GET waited {waited:?}"); } /// R3-3: with its defaults (no cap per peer), eight connections that trickle a head and /// reconnect the moment they are closed never shut a legitimate GET out: on a loopback /// listener every client has their address. #[test] fn origin_serves_past_reconnecting_tricklers() { let root = scratch("origin-reconnect"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &["--read-timeout-ms", "1000"]); let port = origin.port; let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let tricklers: Vec<_> = (0..8).map(|_| { let stop = stop.clone(); std::thread::spawn(move || { while !stop.load(std::sync::atomic::Ordering::SeqCst) { let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) else { continue }; for b in b"GET /x.json HTTP/1.1\r\nX-Slow: ".iter().chain(std::iter::repeat(&b'a')) { if stop.load(std::sync::atomic::Ordering::SeqCst) || s.write_all(&[*b]).is_err() { break; } std::thread::sleep(std::time::Duration::from_millis(50)); } } }) }).collect(); std::thread::sleep(std::time::Duration::from_millis(300)); let t0 = std::time::Instant::now(); let mut answers = Vec::new(); while t0.elapsed() < std::time::Duration::from_secs(3) { answers.push(raw(port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n").lines().next().unwrap_or("").to_string()); std::thread::sleep(std::time::Duration::from_millis(100)); } stop.store(true, std::sync::atomic::Ordering::SeqCst); for t in tricklers { t.join().unwrap(); } assert!(answers.len() >= 10 && answers.iter().all(|a| a.starts_with("HTTP/1.1 200")), "{answers:?}"); } /// R3-3: a connection lives at most `--max-connection-ms` from its accept, so a peer that /// reads a large response slowly is cut off then, not served for as long as it keeps /// reading. #[test] fn origin_cuts_a_slow_reader_at_the_lifetime() { let root = scratch("origin-slow-reader"); let size = 32usize << 20; std::fs::write(root.join("big.bin"), vec![7u8; size]).unwrap(); let origin = Origin::start(&root, &["--max-connection-ms", "1500"]); let mut s = TcpStream::connect(("127.0.0.1", origin.port)).unwrap(); s.write_all(b"GET /big.bin HTTP/1.1\r\nHost: a\r\n\r\n").unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(10))).unwrap(); let (t0, mut got, mut buf) = (std::time::Instant::now(), Vec::new(), [0u8; 4096]); // Slowly, past the lifetime: 4 KiB every 100 ms. while t0.elapsed() < std::time::Duration::from_millis(2500) { match s.read(&mut buf) { Ok(0) | Err(_) => break, Ok(n) => got.extend_from_slice(&buf[..n]), } std::thread::sleep(std::time::Duration::from_millis(100)); } // Then as fast as it comes: what was already sent, and the end of the connection. let ended = loop { match s.read(&mut buf) { Ok(0) => break true, Ok(n) => got.extend_from_slice(&buf[..n]), Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => break false, Err(_) => break true, } }; assert!(got.starts_with(b"HTTP/1.1 200"), "{:?}", String::from_utf8_lossy(&got[..got.len().min(64)])); assert!(ended, "the connection was still open"); assert!(got.len() < size, "the whole response arrived: the connection outlived its lifetime"); } /// N1: at most `--max-per-peer` connections from one address, when it is given; one more /// is answered 503. #[test] fn origin_per_peer_limit() { let root = scratch("origin-peer"); std::fs::write(root.join("x.json"), b"{}").unwrap(); let origin = Origin::start(&root, &["--max-per-peer", "2", "--read-timeout-ms", "2000"]); let idle: Vec = (0..2).map(|_| TcpStream::connect(("127.0.0.1", origin.port)).unwrap()).collect(); std::thread::sleep(std::time::Duration::from_millis(200)); let r = raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n"); assert!(r.starts_with("HTTP/1.1 503"), "{r:?}"); drop(idle); std::thread::sleep(std::time::Duration::from_millis(200)); assert!(raw(origin.port, b"GET /x.json HTTP/1.1\r\nHost: a\r\n\r\n").starts_with("HTTP/1.1 200")); } /// N3: `wai http bench` times the base from the session's `base_ready`: with the origin /// holding the enhancement's bytes back, the base's time does not include the delay, and /// the time to complete does. #[test] fn bench_times_the_base_apart_from_its_refinement() { use wai::http_binding::head::{parse_head, HeadParse}; let root = scratch("bench-delay"); let src = object("O8"); let env = std::fs::read(&src).unwrap(); assert!(wai(&["http", "publish", src.to_str().unwrap(), "--out", root.to_str().unwrap()]).status.success()); let HeadParse::Head(h) = parse_head(&env, env.len() as u64).unwrap() else { panic!("a head") }; let enh = h.components[1]; let delay = format!("{}-{}:1500", enh.start, enh.start + enh.len - 1); let origin = Origin::start(&root, &["--delay-range", &delay]); let rec = root.join("bench.json"); let url = format!("http://127.0.0.1:{}/{}.wai#h={}&n={}", origin.port, hex(&content_hash(&env)), h.covered_len(), env.len()); let run = wai(&["http", "bench", "--url", &url, "--runs", "2", "--advertise", "wai.video.avc", "--out", rec.to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let r: serde_json::Value = serde_json::from_slice(&std::fs::read(&rec).unwrap()).unwrap(); let base = r["ms_to_verified"]["median"].as_f64().unwrap(); let all = r["ms_to_complete"]["median"].as_f64().unwrap(); assert!(base < 1000.0, "the base's time includes the enhancement's delay: {base} ms"); assert!(all >= 1500.0, "the enhancement was not delayed: {all} ms"); // With a refinement deadline shorter than the delay, the enhancement is abandoned. let run = wai(&["http", "bench", "--url", &url, "--runs", "1", "--advertise", "wai.video.avc", "--refinement-deadline-ms", "1", "--out", rec.to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let r: serde_json::Value = serde_json::from_slice(&std::fs::read(&rec).unwrap()).unwrap(); assert_eq!(r["refinements_abandoned"], serde_json::json!([1])); assert!(r["ms_to_complete"]["median"].as_f64().unwrap() < 1000.0); drop(origin); } /// A classical playlist for the O9 segments (three of 640 ms), and an objects list over /// them, written in a scratch directory with the corpus objects beside them. fn o9_presentation(name: &str) -> PathBuf { let d = scratch(name); std::fs::write(d.join("classical.m3u8"), "#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-TARGETDURATION:1\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:0.640,\nc0.m4s\n#EXTINF:0.640,\nc1.m4s\n#EXTINF:0.640,\nc2.m4s\n#EXT-X-ENDLIST\n").unwrap(); let objs: Vec = ["O9a", "O9b", "O9c"].iter().map(|id| object(id).to_str().unwrap().to_string()).collect(); let list = serde_json::json!({ "uri_prefix": "objects/", "variants": [{ "uri": "wai-180.m3u8", "classical": "classical.m3u8", "resolution": [320, 180], "frame_rate_milli": 25000, "segments": objs.iter().map(|o| serde_json::json!({ "object": o })).collect::>() }], }); std::fs::write(d.join("list.json"), serde_json::to_vec_pretty(&list).unwrap()).unwrap(); d } /// `wai http hls` writes the WAI media playlist aligned with the classical one, and /// `wai.m3u8` with the union, the floor and computed bandwidths; `wai http mpd` writes /// `wai.mpd` on the same timeline. #[test] fn hls_and_mpd_write_aligned_playlists() { let d = o9_presentation("hls"); let run = wai(&["http", "hls", "--objects", d.join("list.json").to_str().unwrap(), "--out", d.to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let media = std::fs::read_to_string(d.join("wai-180.m3u8")).unwrap(); let classical = std::fs::read_to_string(d.join("classical.m3u8")).unwrap(); wai::http_binding::hls::check_adjacent(&media, &classical).unwrap(); let cid = hex(&content_hash(&std::fs::read(object("O9b")).unwrap())); // A WAI1 is listed with its n alone; a WAI2 with its h too (§5.4). assert!(media.contains(&format!("#EXTINF:0.640,\nobjects/{cid}.wai#n=18004\n")), "{media}"); assert!(media.contains("wai#n=21034&h=1009\n"), "{media}"); let master = std::fs::read_to_string(d.join("wai.m3u8")).unwrap(); assert_eq!(master, "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=262925,AVERAGE-BANDWIDTH=239246,CODECS=\"wai2.video.av1|video.int_hyper\",X-WAI-FLOOR=\"wai2.video.av1\",RESOLUTION=320x180,FRAME-RATE=25.000\nwai-180.m3u8\n"); let run = wai(&["http", "mpd", "--objects", d.join("list.json").to_str().unwrap(), "--out", d.join("wai.mpd").to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let mpd = std::fs::read_to_string(d.join("wai.mpd")).unwrap(); assert!(mpd.contains("") && mpd.contains(""), "{mpd}"); // An object list that does not cover the classical timeline is refused: three // objects for two classical segments. std::fs::write(d.join("classical.m3u8"), classical.replacen("#EXTINF:0.640,\nc2.m4s\n", "", 1)).unwrap(); let run = wai(&["http", "hls", "--objects", d.join("list.json").to_str().unwrap(), "--out", d.to_str().unwrap()]); assert!(!run.status.success()); } /// `wai http mpd --classical-mpd` takes the classical MPD's period, timescale, `t` and /// `d`, leaves a gap's `S` and `SegmentURL` out, and checks the result adjacent to the /// classical MPD; a list that does not cover the classical timeline is refused. #[test] fn mpd_takes_the_classical_mpd_timeline_and_leaves_gaps_out() { let d = o9_presentation("mpd-classical"); let classical = "\n\n \n \n \n \n \n \n \n \n\n"; std::fs::write(d.join("classical.mpd"), classical).unwrap(); let mut list: serde_json::Value = serde_json::from_slice(&std::fs::read(d.join("list.json")).unwrap()).unwrap(); list["variants"][0]["segments"][1]["gap"] = serde_json::json!(true); std::fs::write(d.join("list.json"), serde_json::to_vec(&list).unwrap()).unwrap(); let args = |out: &str| vec!["http".to_string(), "mpd".into(), "--objects".into(), d.join("list.json").to_str().unwrap().into(), "--out".into(), d.join(out).to_str().unwrap().into(), "--classical-mpd".into(), d.join("classical.mpd").to_str().unwrap().into()]; let run = wai(&args("wai.mpd").iter().map(String::as_str).collect::>()); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let mpd = std::fs::read_to_string(d.join("wai.mpd")).unwrap(); assert!(mpd.contains("") && mpd.contains(""), "{mpd}"); assert!(mpd.contains("") && mpd.contains("") && !mpd.contains("t=\"57600\""), "{mpd}"); assert_eq!(mpd.matches(">()); assert!(!run.status.success()); assert!(String::from_utf8_lossy(&run.stderr).contains("3 segments, and the classical timeline has 2"), "{}", String::from_utf8_lossy(&run.stderr)); } /// `wai http seal-playlist` writes `mr/.json` and /// `mr/.json`; `check-playlist` reports it signed, refuses a changed /// playlist, and holds a revision to the head it chains to, walking the revisions /// between. #[cfg(feature = "video_manifest")] #[test] fn seal_and_check_playlist() { let d = o9_presentation("seal"); let run = wai(&["http", "hls", "--objects", d.join("list.json").to_str().unwrap(), "--out", d.to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let pl = d.join("wai-180.m3u8"); let key = "07".repeat(32); let run = wai(&["http", "seal-playlist", pl.to_str().unwrap(), "--key", &key, "--signer-id", "did:example:cli", "--content-id", "o9"]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let bytes = std::fs::read(&pl).unwrap(); let name = format!("{}.json", blake3::hash(&bytes).to_hex()); let receipt = d.join("mr").join(&name); assert!(String::from_utf8_lossy(&run.stdout).starts_with(receipt.to_str().unwrap())); let ok = wai(&["http", "check-playlist", pl.to_str().unwrap(), "--receipt", receipt.to_str().unwrap(), "--classical", d.join("classical.m3u8").to_str().unwrap()]); assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stdout)); assert!(String::from_utf8_lossy(&ok.stdout).starts_with("signed\t")); let mut changed = bytes.clone(); let at = changed.len() - 20; changed[at] ^= 1; std::fs::write(d.join("changed.m3u8"), &changed).unwrap(); let bad = wai(&["http", "check-playlist", d.join("changed.m3u8").to_str().unwrap(), "--receipt", receipt.to_str().unwrap()]); assert_eq!(bad.status.code(), Some(1)); assert_eq!(String::from_utf8_lossy(&bad.stdout), "refused\tplaylist_receipt_mismatch\n"); // With no grant, one of no ceilings, written null; under the publisher's grant, its // ceilings; a ceiling past 2^53 - 1 is not sealed. assert!(String::from_utf8_lossy(&std::fs::read(&receipt).unwrap()).contains("\"joule_ceiling_micro\":null")); let grant = d.join("grant.json"); std::fs::write(&grant, format!("{{\"grant_hash\":\"{}\",\"capability\":\"video.manifest\",\"joule_ceiling_micro\":5000000,\"funds_ceiling\":null}}", "ab".repeat(32))).unwrap(); let granted = wai(&["http", "seal-playlist", pl.to_str().unwrap(), "--key", &key, "--grant", grant.to_str().unwrap(), "--out", d.join("granted").to_str().unwrap()]); assert!(granted.status.success(), "{}", String::from_utf8_lossy(&granted.stderr)); let file = String::from_utf8(std::fs::read(d.join("granted/mr").join(&name)).unwrap()).unwrap(); assert!(file.contains("\"joule_ceiling_micro\":5000000") && file.contains(&"ab".repeat(32)), "{file}"); let ok = wai(&["http", "check-playlist", pl.to_str().unwrap(), "--receipt", d.join("granted/mr").join(&name).to_str().unwrap()]); assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stdout)); std::fs::write(&grant, format!("{{\"grant_hash\":\"{}\",\"capability\":\"video.manifest\",\"joule_ceiling_micro\":9007199254740992,\"funds_ceiling\":null}}", "ab".repeat(32))).unwrap(); let past = wai(&["http", "seal-playlist", pl.to_str().unwrap(), "--key", &key, "--grant", grant.to_str().unwrap(), "--out", d.join("past").to_str().unwrap()]); assert!(!past.status.success()); // Each receipt is also written under its receipt hash. let hash_of = |out: &std::process::Output| String::from_utf8_lossy(&out.stdout).trim_end().rsplit(' ').next().unwrap().to_string(); let r1_hash = hash_of(&run); assert_eq!(std::fs::read(d.join("mr").join(format!("{r1_hash}.json"))).unwrap(), std::fs::read(&receipt).unwrap()); // A second revision sealed with the first as its parent checks against it; the // first fetched again is the head unchanged. let run = wai(&["http", "seal-playlist", pl.to_str().unwrap(), "--key", &key, "--parent", receipt.to_str().unwrap(), "--out", d.join("r2").to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let r2 = d.join("r2/mr").join(&name); let ok = wai(&["http", "check-playlist", pl.to_str().unwrap(), "--receipt", r2.to_str().unwrap(), "--parent", receipt.to_str().unwrap()]); assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stdout)); assert!(String::from_utf8_lossy(&ok.stdout).contains("\tchained (0 parents walked)")); let same = wai(&["http", "check-playlist", pl.to_str().unwrap(), "--receipt", receipt.to_str().unwrap(), "--parent", receipt.to_str().unwrap()]); assert!(same.status.success(), "{}", String::from_utf8_lossy(&same.stdout)); // A third revision, chained to the second, checked against the first: the second is // fetched by its link from the third's directory; without it the walk stops. let r2_file = d.join("r2/mr").join(format!("{}.json", hash_of(&run))); let run = wai(&["http", "seal-playlist", pl.to_str().unwrap(), "--key", &key, "--parent", r2_file.to_str().unwrap(), "--out", d.join("r2").to_str().unwrap()]); assert!(run.status.success(), "{}", String::from_utf8_lossy(&run.stderr)); let ok = wai(&["http", "check-playlist", pl.to_str().unwrap(), "--receipt", r2.to_str().unwrap(), "--parent", receipt.to_str().unwrap()]); assert!(String::from_utf8_lossy(&ok.stdout).contains("\tchained (1 parents walked)"), "{}", String::from_utf8_lossy(&ok.stdout)); std::fs::remove_file(&r2_file).unwrap(); let bad = wai(&["http", "check-playlist", pl.to_str().unwrap(), "--receipt", r2.to_str().unwrap(), "--parent", receipt.to_str().unwrap()]); assert_eq!(String::from_utf8_lossy(&bad.stdout), "refused\tplaylist_receipt_parent_missing\n"); } /// The origin serves a playlist to be revalidated on every use, and its receipt /// immutable. #[test] fn origin_serves_playlists_revalidated() { let d = o9_presentation("origin-playlists"); let run = wai(&["http", "hls", "--objects", d.join("list.json").to_str().unwrap(), "--out", d.to_str().unwrap()]); assert!(run.status.success()); std::fs::write(d.join("wai.mpd"), "\n").unwrap(); let o = Origin::start(&d, &[]); let (h, body) = get(o.port, "/wai-180.m3u8", &[]); assert!(h.contains("Content-Type: application/vnd.apple.mpegurl\r\n") && h.contains("Cache-Control: no-cache, no-transform\r\n"), "{h}"); assert_eq!(body, std::fs::read(d.join("wai-180.m3u8")).unwrap()); let (h, _) = get(o.port, "/wai.mpd", &[]); assert!(h.contains("Content-Type: application/dash+xml\r\n") && h.contains("Cache-Control: no-cache, no-transform\r\n"), "{h}"); } /// The replayed live edge (§11.4): the playlist as its current revision; a blocking /// reload for a part answered by a revision that lists it; a hinted part's alias held /// until a revision lists it, and answered with the object whose content hash is the cid /// that revision lists; an alias served with another part's bytes, which that cid /// refuses; and a hint no revision ever fills, not found once the edge ends. Nothing here /// reads a clock: each answer is checked by what it holds. #[test] fn origin_replays_a_live_edge() { let root = corpus(); let revs: Vec = (0..4).map(|k| format!("live/wai-r{k}.m3u8")).collect(); let texts: Vec> = revs.iter().map(|r| std::fs::read(root.join(r)).unwrap()).collect(); let edge = format!("live/wai-720.m3u8={}", revs.join(",")); let o = Origin::start(&root, &["--live-edge", &edge, "--live-step-ms", "400", "--live-swap", "720/1201.7.wai=720/1201.8.wai"]); let (h, body) = get(o.port, "/live/wai-720.m3u8", &[]); assert!(h.starts_with("HTTP/1.1 200") && h.contains("Cache-Control: no-cache, no-transform\r\n"), "{h}"); assert!(texts.contains(&body), "the playlist is one of its revisions"); // A blocking reload more than two segments past the edge, or a part with no segment, // is refused at once (RFC 8216bis §6.2.5.2), not held. // (A held reload is answered 200 with a revision; 400 is the refusal.) for q in ["_HLS_msn=1300", "_HLS_msn=1300&_HLS_part=0", "_HLS_part=1", "_HLS_msn=x", "_HLS_msn=-1"] { let (h, _) = get(o.port, &format!("/live/wai-720.m3u8?{q}"), &[]); assert!(h.starts_with("HTTP/1.1 400"), "{q}: {h}"); } // Part 2 of segment 1201 is first listed by revision 1. let (_, body) = get(o.port, "/live/wai-720.m3u8?_HLS_msn=1201&_HLS_part=2", &[]); let p = wai::http_binding::hls::parse_media_playlist(&String::from_utf8(body.clone()).unwrap()).unwrap(); assert!(wai::http_binding::hls::listed_cid(&p, "720/1201.2.wai").is_some(), "the blocking reload lists the part"); assert!(texts[1..].contains(&body)); // Revision 0 hints 1200.5; revision 1 lists it with its cid. let r1 = wai::http_binding::hls::parse_media_playlist(&String::from_utf8(texts[1].clone()).unwrap()).unwrap(); let (h, part) = get(o.port, "/live/720/1200.5.wai", &[]); assert!(h.starts_with("HTTP/1.1 200"), "{h}"); assert_eq!(Some(content_hash(&part)), wai::http_binding::hls::listed_cid(&r1, "720/1200.5.wai")); // Served with another part's bytes: the cid the playlist lists refuses them. let r2 = wai::http_binding::hls::parse_media_playlist(&String::from_utf8(texts[2].clone()).unwrap()).unwrap(); let (_, swapped) = get(o.port, "/live/720/1201.7.wai", &[]); assert_ne!(Some(content_hash(&swapped)), wai::http_binding::hls::listed_cid(&r2, "720/1201.7.wai")); assert_eq!(Some(content_hash(&swapped)), wai::http_binding::hls::listed_cid(&r2, "720/1201.8.wai")); // Revision 2 hints 1202.0, which no revision fills: not found when the edge ends, // and the playlist is then its last revision, which ends. let (h, _) = get(o.port, "/live/720/1202.0.wai", &[]); assert!(h.starts_with("HTTP/1.1 404"), "{h}"); let (_, body) = get(o.port, "/live/wai-720.m3u8", &[]); assert_eq!(body, texts[3]); // A byte range of a part, under its alias. let (h, b) = get(o.port, "/live/720/1200.5.wai", &[("Range", "bytes=0-7")]); assert!(h.starts_with("HTTP/1.1 206") && b == part[..8], "{h}"); }