//! No signer id written in the WAI tree names a real web origin (jwp-receipts §7.1, §7.8). //! //! An origin-bound `signer_id` — `did:web:[%3A]#` — says that the //! origin's key log lists the signer's key. Every key the tree signs with comes from a //! seed published in it, and a published key attests nothing, so no id the tree writes may //! name an origin a verifier could anchor, nor a domain name at all, even in a spelling //! that is a label today. This test reads every tracked `.rs`, `.json` and `.js` file //! under `wai/` and checks each `did:web:` and `did:key:` literal: a `did:web` literal //! whose host is a domain name, with or without a fragment, must be under a reserved //! example name (`.example`, `.test`, `.invalid`, RFC 2606), which no one can publish a //! key log for; an origin-bound one must be too; anything else is a label or an Ed25519 //! `did:key`, which names one key and no origin. `signer-conformance/`, whose vectors are //! about such ids, is skipped. //! //! The files are listed with `git ls-files`, so an untracked scratch file does not fail //! it. Where git cannot list them — not installed, or refusing a checkout another user //! owns — the test walks the directory instead, leaving out build output and dependencies. use std::path::{Path, PathBuf}; use std::process::Command; use wai::signer_id::{classify, SignerIdForm}; const RESERVED: &[&str] = &[".example", ".test", ".invalid"]; const EXTENSIONS: &[&str] = &["rs", "json", "js"]; fn tracked(wai: &Path) -> Vec { let listed = Command::new("git") .arg("-C") .arg(wai) .args(["ls-files", "-z", "--", "*.rs", "*.json", "*.js"]) .output() .ok() .filter(|o| o.status.success() && !o.stdout.is_empty()); match listed { Some(out) => out .stdout .split(|b| *b == 0) .filter(|p| !p.is_empty()) .map(|p| wai.join(String::from_utf8_lossy(p).as_ref())) .collect(), None => { eprintln!("git cannot list {}; walking the directory instead", wai.display()); let mut out = Vec::new(); walk(wai, &mut out); out } } } /// Every file with one of [`EXTENSIONS`] under `dir`, leaving out hidden directories, /// build output and dependencies. fn walk(dir: &Path, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; for e in entries.flatten() { let p = e.path(); let name = e.file_name().to_string_lossy().into_owned(); if p.is_dir() { if !name.starts_with('.') && !matches!(name.as_str(), "target" | "node_modules" | "pkg" | "dist") { walk(&p, out); } } else if p.extension().and_then(|x| x.to_str()).is_some_and(|x| EXTENSIONS.contains(&x)) { out.push(p); } } } /// The host a `did:web:` literal names, where it is a domain name — dot-separated labels /// of letters, digits and `-`, the last not all digits — or `None`. fn domain_of(lit: &str) -> Option { let rest = lit.strip_prefix("did:web:")?; let host = rest.split(['#', '%', ':', '/']).next().unwrap_or("").to_ascii_lowercase(); let labels: Vec<&str> = host.split('.').collect(); let domain = labels.len() >= 2 && labels.iter().all(|l| !l.is_empty() && l.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')) && !labels.last().is_some_and(|l| l.bytes().all(|b| b.is_ascii_digit())); domain.then_some(host) } fn reserved(host: &str) -> bool { RESERVED.iter().any(|t| host.ends_with(t)) } /// Every `did:web:…` and `did:key:…` run of identifier characters in `text`. fn literals(text: &str) -> Vec { let ok = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '%' | '#'); let mut out = Vec::new(); for start in ["did:web:", "did:key:"] { let mut from = 0; while let Some(i) = text[from..].find(start) { let at = from + i; let end = text[at..].find(|c: char| !ok(c)).map_or(text.len(), |e| at + e); out.push(text[at..end].trim_end_matches(['.', ':']).to_owned()); from = end.max(at + start.len()); } } out } #[test] fn every_signer_id_in_the_tree_is_a_label() { let wai = Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("wai-rs sits in wai/").to_path_buf(); let files = tracked(&wai); assert!(files.len() > 50, "too few files listed under {}: {}", wai.display(), files.len()); let mut bad = Vec::new(); let mut seen = 0usize; for f in files { let rel = f.strip_prefix(&wai).unwrap_or(&f).to_string_lossy().replace('\\', "/"); if rel.starts_with("signer-conformance/") { continue; } let Ok(text) = std::fs::read_to_string(&f) else { continue }; for lit in literals(&text) { seen += 1; if let Some(host) = domain_of(&lit) && !reserved(&host) { bad.push(format!("{rel}: {lit} names the domain {host}")); continue; } if let SignerIdForm::OriginBound { origin, .. } = classify(&lit) { let host = origin.trim_start_matches("https://").split(':').next().unwrap_or(""); if !reserved(host) { bad.push(format!("{rel}: {lit} names the origin {origin}")); } } } } assert!(seen > 0, "no did: literal found: the scan read nothing"); assert!(bad.is_empty(), "signer ids naming a domain outside reserved names:\n{}", bad.join("\n")); } /// A `did:web` literal naming a domain is caught with or without a fragment; one under a /// reserved name, or with a host that is not a domain name, is not. The caught cases are /// spelled in two pieces, so this file does not hold what the scan above refuses, and /// their top-level label is not a delegated one. #[test] fn a_domain_is_caught_whatever_the_spelling() { let w = |rest: &str| format!("{}{rest}", concat!("did:", "web:")); for (lit, caught) in [ (w("wai.signer.zz"), true), (w("Wai.Signer.zz#k"), true), (w("wai.signer.zz%3A8443#k"), true), (w("wai.signer.zz:users:a"), true), (w("publisher.example"), false), (w("publisher.example%3A8443#k"), false), (w("wai#prov"), false), (w("meter"), false), (w("127.0.0.1#d1"), false), ] { assert_eq!(domain_of(&lit).is_some_and(|h| !reserved(&h)), caught, "{lit}"); } } #[test] fn the_scan_finds_what_it_must() { let found = literals(r#"x "did:web:wai.example#m", `did:key:wai-${k}` did:web:a.example%3A8443#k1. did:key:z6Mk"#); assert_eq!(found, vec!["did:web:wai.example#m", "did:web:a.example%3A8443#k1", "did:key:wai-", "did:key:z6Mk"]); assert!(matches!(classify(&found[0]), SignerIdForm::OriginBound { .. })); }