//! MoQ files and V3C tracks (WAI `extensions/live-recording.md` §8.7 and //! `extensions/moq-streaming-format.md` §4.5): the committed corpora //! `wai/moqt-conformance/moq-file/` and `wai/moqt-conformance/v3c/vectors.json` regenerate byte //! for byte, every case and refusal in them holds, a take written to a directory reads back //! with receipts that verify, and each text lists exactly the codes its module gives. The //! corpora live beside this crate in the monorepo; if one cannot be read the test fails, it //! never passes on nothing. use std::path::{Path, PathBuf}; use joule_moq::file::{self, corpus as file_corpus, FileError, FileRefusal}; use joule_moq::v3c::{self, V3cRefusal}; const WAI: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/"); fn corpus_dir() -> PathBuf { PathBuf::from(format!("{WAI}moqt-conformance/moq-file")) } /// A directory of this test's own under the target directory, emptied first. fn scratch(name: &str) -> PathBuf { let d = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(format!("oneway-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).expect("a scratch directory"); d } fn listed(text_path: &str, heading: &str) -> Vec { let text = std::fs::read_to_string(format!("{WAI}{text_path}")).expect("the extension"); let at = text.find(heading).unwrap_or_else(|| panic!("{text_path} has no {heading:?}")); let end = text[at + 1..].find("\n#").map_or(text.len(), |e| at + 1 + e); text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1)).map(str::to_string).collect() } #[test] fn the_file_corpus_regenerates() { let dir = corpus_dir(); let files = file_corpus::generate(); assert!(files.len() >= 7); for (name, bytes) in &files { let committed = std::fs::read(dir.join(name)).unwrap_or_else(|_| panic!("{name} of the corpus")); assert!(&committed == bytes, "{name} differs: run `cargo run -p joule-moq --example oneway_vectors -- gen`"); } // And the corpus holds no file the generator does not write, but its README and verifier. let mut found = Vec::new(); for sub in ["", "take"] { for e in std::fs::read_dir(dir.join(sub)).expect("the corpus directory") { let p = e.expect("an entry").path(); if p.is_file() { found.push(p.strip_prefix(&dir).expect("under the corpus").to_string_lossy().replace('\\', "/")); } } } found.retain(|f| f != "README.md"); let mut want: Vec = files.iter().map(|f| f.0.clone()).collect(); found.sort(); want.sort(); assert_eq!(found, want); } #[test] fn every_file_case_holds() { let fails = file_corpus::verify(&corpus_dir()); assert!(fails.is_empty(), "{}", fails.join("\n")); } #[test] fn a_take_written_to_a_directory_reads_back_with_receipts_that_verify() { let (objects, key) = file_corpus::take(); let dir = scratch("take"); let written = file::write_dir(&dir, &objects).expect("the take writes"); assert_eq!(written.len(), 3); let back = file::read_dir(&dir).expect("the take reads"); assert_eq!(back.len(), objects.len()); for o in &objects { assert!(back.contains(o), "{}/{} did not come back byte for byte", o.group_id, o.object_id); } assert_eq!(file_corpus::check_receipts(&back, &key), Vec::::new()); // Written again from what was read, the files are the same bytes. let again = scratch("take-again"); file::write_dir(&again, &back).expect("it writes again"); for p in &written { for ext in ["moq", "dat"] { let name = p.with_extension(ext); let name = name.file_name().expect("a file name"); assert_eq!(std::fs::read(dir.join(name)).expect("first"), std::fs::read(again.join(name)).expect("second"), "{name:?}"); } } let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&again); } fn refusal(r: Result, FileError>) -> Option { match r { Err(FileError::Refused(r)) => Some(r), _ => None, } } /// The planted faults, on files on disk: a data file cut short, a `.moq` file cut short, a /// payload changed under its receipt, and a `.moq` file that names a file outside its directory. #[test] fn a_truncated_or_changed_file_is_caught() { let (objects, key) = file_corpus::take(); let dir = scratch("faults"); let written = file::write_dir(&dir, &objects).expect("the take writes"); let video = written.iter().find(|p| p.to_string_lossy().ends_with("-video.moq")).expect("the video track").clone(); let dat = video.with_extension("dat"); let (meta, data) = (std::fs::read(&video).expect("meta"), std::fs::read(&dat).expect("data")); std::fs::write(&dat, &data[..data.len() - 1]).expect("truncate"); assert_eq!(refusal(file::read_dir(&dir)), Some(FileRefusal::DataTruncated)); std::fs::write(&dat, &data[..data.len() / 2]).expect("truncate"); assert_eq!(refusal(file::read_dir(&dir)), Some(FileRefusal::DataTruncated)); std::fs::write(&dat, &data).expect("restore"); for cut in [meta.len() - 2, meta.len() / 2, 1] { std::fs::write(&video, &meta[..cut]).expect("truncate"); assert_eq!(refusal(file::read_dir(&dir)), Some(FileRefusal::MetaMalformed), "cut at {cut}"); } std::fs::write(&video, &meta).expect("restore"); assert!(file::read_dir(&dir).is_ok()); // One payload byte changed: the file still reads, and the receipt refuses the object. let mut changed = data.clone(); changed[3] ^= 1; std::fs::write(&dat, &changed).expect("change"); let back = file::read_dir(&dir).expect("it reads"); let fails = file_corpus::check_receipts(&back, &key); assert_eq!(fails.len(), 1, "{fails:?}"); assert!(fails[0].starts_with("video 0/0"), "{fails:?}"); std::fs::write(&dat, &data).expect("restore"); // A data file outside the directory: by its name, and through a link inside it. let outside = scratch("faults-outside"); std::fs::write(outside.join("secret.dat"), &data).expect("a file elsewhere"); let text = String::from_utf8(meta.clone()).expect("UTF-8"); let stem = dat.file_name().expect("a name").to_string_lossy().to_string(); let up = format!("../{}/secret.dat", outside.file_name().expect("a name").to_string_lossy()); std::fs::write(&video, text.replace(&stem, &up)).expect("rewrite"); assert_eq!(refusal(file::read_dir(&dir)), Some(FileRefusal::DataPathRefused)); #[cfg(unix)] { std::os::unix::fs::symlink(outside.join("secret.dat"), dir.join("link.dat")).expect("a link"); std::fs::write(&video, text.replace(&stem, "link.dat")).expect("rewrite"); assert_eq!(refusal(file::read_dir(&dir)), Some(FileRefusal::DataPathRefused)); } let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&outside); } #[test] fn live_recording_lists_the_file_codes() { let want: Vec<&str> = FileRefusal::all().iter().map(|r| r.code()).collect(); assert_eq!(listed("extensions/live-recording.md", "\n### 8.7 "), want); } #[test] fn the_v3c_corpus_regenerates_and_holds() { let path = Path::new(WAI).join("moqt-conformance/v3c/vectors.json"); let committed = std::fs::read_to_string(&path).expect("the V3C corpus"); assert!(v3c::corpus::generate() == committed, "run `cargo run -p joule-moq --example oneway_vectors -- gen`"); let doc: serde_json::Value = serde_json::from_str(&committed).expect("JSON"); let fails = v3c::corpus::verify(&doc); assert!(fails.is_empty(), "{}", fails.join("\n")); } #[test] fn moq_streaming_format_lists_the_v3c_codes() { let want: Vec<&str> = V3cRefusal::all().iter().map(|r| r.code()).collect(); assert_eq!(listed("extensions/moq-streaming-format.md", "\n### 4.5 "), want); } /// The catalog this crate writes for a track of `wai.pc.vpcc` objects and its V3C tracks, /// held to the committed sample `wai/ietf/catalog_check.py` reads. `JOULE_MOQ_WRITE_CATALOGS=1` /// writes it; never set in CI. #[test] fn the_v3c_catalog_is_the_committed_sample() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/catalogs/v3c.json"); let text = v3c::corpus::sample_catalog(); if std::env::var("JOULE_MOQ_WRITE_CATALOGS").as_deref() == Ok("1") { std::fs::create_dir_all(path.parent().expect("a directory")).expect("the directory"); std::fs::write(&path, &text).expect("write the sample"); } assert!(std::fs::read_to_string(&path).expect("the committed sample") == text, "run JOULE_MOQ_WRITE_CATALOGS=1 cargo test -p joule-moq --test oneway_vectors"); // initDataList follows tracks (MSF §5.1.7), and the V3C tracks are the WAI track's wai_v3c. assert!(text.find("\"tracks\"").expect("tracks") < text.find("\"initDataList\"").expect("initDataList")); let doc: serde_json::Value = serde_json::from_str(&text).expect("JSON"); assert_eq!(doc["tracks"][0]["wai_v3c"]["tracks"].as_array().expect("tracks")[..], doc["tracks"].as_array().expect("tracks")[1..]); assert_eq!(doc["tracks"][0]["wai_v3c"]["initData"], doc["initDataList"][0]); }