diff --git a/knot2/crates/knot-xrpc/tests/private_surface.rs b/knot2/crates/knot-xrpc/tests/private_surface.rs new file mode 100644 index 000000000..f08d8bce7 --- /dev/null +++ b/knot2/crates/knot-xrpc/tests/private_surface.rs @@ -0,0 +1,1452 @@ +mod common; + +use std::collections::{BTreeMap, BTreeSet}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; + +use axum::Router; +use axum::body::Body; +use axum::extract::ConnectInfo; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use http::{Request, StatusCode, header}; +use k256::ecdsa::signature::Signer; +use k256::ecdsa::{Signature, SigningKey}; +use serde_json::json; +use tower::ServiceExt; + +use knot_index::Resolved; +use knot_lfs::{ClaimedSize, LfsOid, LfsStore, Pool}; +use knot_pack::HandleResolver; +use knot_types::{AccountDid, Handle, Oid, RepoDid, ServiceDid, Syncers, UnixSeconds, Visibility}; +use knot_xrpc::{ROUTES, RouteKind, RouteSpec}; + +use common::{ + Answer, NO_DATE, World, answer_from, commit_file, declared, enable_chain, ghost, name_spindle, + nsid, owner, post_as, request, sh_git, sh_git_at, +}; + +const FETCH: &str = "org.tangled.git.uploadPack"; +const PUSH: &str = "org.tangled.git.receivePack"; +const TICKET: &str = "org.tangled.track.ticket"; +const COMMENT: &str = "org.tangled.feed.comment"; +const MARKUP: &str = "org.tangled.markup.markdown"; +const CREATE_RECORD: &str = "com.atproto.repo.createRecord"; +const NOT_A_ROUTE_LXM: &str = "org.tangled.knot.describeKnot"; +const MEDIA: &[u8] = b"media that only members may fetch"; +const PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, + 0x89, +]; +const HANDLE: &str = "olaren.dev"; + +fn account(did: &str) -> AccountDid { + AccountDid::new(did).unwrap() +} + +fn helper() -> AccountDid { + account("did:plc:helper") +} + +fn blocked() -> AccountDid { + account("did:plc:blocked") +} + +fn removed() -> AccountDid { + account("did:plc:removed") +} + +fn neighbour() -> AccountDid { + account("did:plc:neighbour") +} + +fn undeclared() -> AccountDid { + account("did:web:deliberi.example") +} + +fn wide() -> AccountDid { + common::bobbin() +} + +fn narrow() -> AccountDid { + account("did:web:narrow.example") +} + +fn ci() -> AccountDid { + account("did:web:ci.example") +} + +fn at(seconds: i64) -> UnixSeconds { + UnixSeconds::new(seconds) +} + +fn every_read_lxm() -> Vec<&'static str> { + let mut lxms: BTreeSet<&'static str> = ROUTES + .iter() + .filter(|route| { + matches!( + route.kind, + RouteKind::Read | RouteKind::Git | RouteKind::Lfs + ) + }) + .filter_map(|route| route.lxm) + .collect(); + lxms.extend([FETCH, PUSH]); + lxms.into_iter().collect() +} + +fn every_write_lxm() -> Vec<&'static str> { + ROUTES + .iter() + .filter(|route| route.kind == RouteKind::Write) + .filter_map(|route| route.lxm) + .collect() +} + +fn syncers() -> Syncers { + let mut allowed = every_read_lxm(); + allowed.extend(every_write_lxm()); + Syncers::new(vec![ + declared(wide().as_str(), &allowed), + declared(narrow().as_str(), &[NOT_A_ROUTE_LXM]), + ]) + .unwrap() +} + +struct FakeHandles; + +impl HandleResolver for FakeHandles { + fn resolve( + &self, + handle: Handle, + ) -> std::pin::Pin> + Send + '_>> { + Box::pin(async move { (handle.as_str() == HANDLE).then(owner) }) + } +} + +fn router_for(world: &World) -> Router { + let (write_routes, advertisement) = knot_pack::edge_routes(knot_pack::EdgeConfig { + receive: Some(knot_xrpc::receive_advertiser(Arc::clone(&world.state))), + authenticator: Some(knot_xrpc::read_authenticator(Arc::clone(&world.state))), + handle_resolver: Some(Arc::new(FakeHandles)), + ..knot_pack::EdgeConfig::serving( + knot_acl::Repos::new(world.layout.clone()), + knot_pack::index_resolver(Arc::clone(&world.state.index)), + Arc::new(knot_runtime::SystemClock), + ) + }); + write_routes + .merge(advertisement.into_router()) + .merge(world.router.clone()) + .layer(axum::middleware::from_fn( + |mut request: axum::extract::Request, next: axum::middleware::Next| async move { + if request.extensions().get::().is_none() { + request.extensions_mut().insert(knot_pack::EarlyData::No); + } + next.run(request).await + }, + )) +} + +fn jwt(lxm: &str, issuer: &AccountDid, audience: &str, expires: i64) -> String { + static NONCE: AtomicU64 = AtomicU64::new(0); + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); + let claims = json!({ + "iss": issuer.as_str(), + "aud": audience, + "exp": expires, + "iat": expires - 2, + "jti": format!("surface-{}", NONCE.fetch_add(1, Ordering::SeqCst)), + "lxm": lxm, + }); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let signing_input = format!("{header}.{payload}"); + let key = SigningKey::from_bytes(&[9u8; 32].into()).unwrap(); + let signature: Signature = key.sign(signing_input.as_bytes()); + format!( + "Bearer {signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + ) +} + +fn knot_audience() -> String { + common::knot_service_did().to_string() +} + +// the clock starts at 1_000 seconds so one is still live and the other long expired +const LIVE: i64 = 1_001; +const EXPIRED: i64 = 500; + +#[derive(Clone)] +struct Facts { + head: String, + first: String, + ticket: String, + patch: String, + ticket_cid: String, + blob: String, + object: String, +} + +struct Site { + did: RepoDid, + name: &'static str, + facts: Facts, + _work: tempfile::TempDir, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Depth { + Bare, + Rich, +} + +async fn site( + world: &World, + name: &'static str, + visibility: Visibility, + depth: Depth, + loaded: bool, +) -> Site { + let did = RepoDid::new(format!("did:plc:{name}fixture")).unwrap(); + world.layout.create(&did).unwrap(); + world.register_as( + &did, + &knot_types::RepoName::new(name).unwrap(), + &knot_types::RepoRkey::new(name).unwrap(), + visibility, + ); + if loaded { + world.state.index.ensure_collaborators(&did).unwrap(); + } + let work = tempfile::tempdir().unwrap(); + let dir = work.path(); + sh_git(dir, &["init", "-q", "-b", "main"]); + commit_file( + dir, + "README.md", + b"# vault\n", + "first", + "2026-06-01T12:30:00+02:00", + ); + commit_file( + dir, + "src/main.rs", + b"fn main() {}\n", + "add main", + "2026-06-01T12:31:00+02:00", + ); + commit_file( + dir, + "logo.png", + b"\x89PNG\r\n\x1a\n0000binarybytes\x00\x01", + "add logo", + "2026-06-01T12:32:00+02:00", + ); + sh_git_at( + dir, + "2026-06-01T12:32:30+02:00", + &["tag", "-a", "v1.0.0", "-m", "release one"], + ); + sh_git(dir, &["branch", "feature"]); + commit_file( + dir, + "README.md", + b"# vault\n\nmore\n", + "update readme", + "2026-06-01T12:33:00+02:00", + ); + let bare = world.layout.repo_path(&did).unwrap(); + sh_git( + dir, + &[ + "push", + "-q", + "--tags", + bare.to_str().unwrap(), + "main", + "feature", + ], + ); + sh_git(&bare, &["symbolic-ref", "HEAD", "refs/heads/main"]); + let rev = |spec: &str| sh_git(dir, &["rev-parse", spec]).trim().to_string(); + let mut facts = Facts { + head: rev("HEAD"), + first: rev("HEAD~3"), + ticket: "3jzfcijpj2z2a".to_string(), + patch: "3jzfcijpj2z2b".to_string(), + ticket_cid: String::new(), + blob: String::new(), + object: LfsOid::from_digest(knot_types::Sha256Digest::hash(MEDIA)).to_string(), + }; + if depth == Depth::Rich { + enrich(world, &did, &mut facts).await; + } + Site { + did, + name, + facts, + _work: work, + } +} + +async fn enrich(world: &World, did: &RepoDid, facts: &mut Facts) { + let audience = ServiceDid::new(did.as_str()).unwrap(); + enable_chain(world, did); + let upload = Request::post("/xrpc/com.atproto.repo.uploadBlob") + .header( + header::AUTHORIZATION, + jwt("com.atproto.repo.uploadBlob", &owner(), did.as_str(), LIVE), + ) + .header(header::CONTENT_TYPE, "image/png") + .body(Body::from(PNG.to_vec())) + .unwrap(); + let uploaded = world.router.clone().oneshot(upload).await.unwrap(); + assert_eq!(uploaded.status(), StatusCode::OK); + let uploaded: serde_json::Value = serde_json::from_slice( + &axum::body::to_bytes(uploaded.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + facts.blob = uploaded["blob"]["ref"]["$link"] + .as_str() + .unwrap() + .to_string(); + + let (status, ticket) = post_as( + world, + &nsid(CREATE_RECORD), + &owner(), + &audience, + json!({ + "repo": did, + "collection": TICKET, + "record": { + "$type": TICKET, + "repo": did, + "title": "kelp with a picture", + "body": { + "$type": MARKUP, + "text": "a forest", + "blobs": [{ + "$type": "org.tangled.markup.embed.image", + "image": uploaded["blob"], + }], + }, + "state": "open", + "createdAt": "2026-09-30T00:00:00Z", + }, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{ticket}"); + let ticket_rkey = common::rkey_of(&ticket); + facts.ticket = ticket_rkey.to_string(); + facts.ticket_cid = ticket["cid"].as_str().unwrap().to_string(); + + let (status, comment) = post_as( + world, + &nsid(CREATE_RECORD), + &owner(), + &audience, + json!({ + "repo": did, + "collection": COMMENT, + "record": { + "$type": COMMENT, + "subject": { "uri": ticket["uri"], "cid": ticket["cid"] }, + "body": { "$type": MARKUP, "text": "pretty" }, + "createdAt": "2026-09-30T00:00:01Z", + }, + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{comment}"); + + let (patch, _) = common::create_patch( + world, + did, + &ticket_rkey, + &Oid::from_hex(&facts.first).unwrap(), + &Oid::from_hex(&facts.head).unwrap(), + ) + .await; + facts.patch = patch.to_string(); + + let oid = LfsOid::from_digest(knot_types::Sha256Digest::hash(MEDIA)); + world + .lfs + .as_ref() + .unwrap() + .put( + Pool::Lfs, + did, + &oid, + ClaimedSize::new(MEDIA.len() as u64), + &mut &MEDIA[..], + ) + .unwrap(); +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + Full, + Warming, + CollaboratorsUnloaded, +} + +struct Rig { + world: World, + router: Router, + hidden: Site, + open: Site, +} + +async fn rig(mode: Mode, depth: Depth) -> Rig { + let world = match mode { + Mode::Warming => World::warming(), + _ => World::declaring(syncers()), + }; + let full = mode == Mode::Full; + let hidden = site(&world, "vault", Visibility::Private, depth, full).await; + let open = site(&world, "daisy", Visibility::Public, depth, full).await; + if full { + world.add_collaborator(&hidden.did, &helper(), &owner(), at(1_000)); + world.add_collaborator(&hidden.did, &blocked(), &owner(), at(1_000)); + world.ban(&blocked(), at(1_001)); + world.add_collaborator(&hidden.did, &removed(), &owner(), at(1_000)); + world.remove_collaborator(&hidden.did, &removed(), at(1_001)); + let attic = site(&world, "attic", Visibility::Private, Depth::Bare, true).await; + world.add_collaborator(&attic.did, &neighbour(), &owner(), at(1_000)); + name_spindle(&world, &attic.did, Some(&ci())); + drop(attic); + } + if mode == Mode::CollaboratorsUnloaded { + world.state.index.rebuild().unwrap(); + } + let router = router_for(&world); + Rig { + world, + router, + hidden, + open, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Viewer { + Anonymous, + GarbageToken, + WrongMethodToken, + ExpiredToken, + WrongAudienceToken, + Stranger, + StrangerWithRepoAudience, + BlockedCollaborator, + RemovedCollaborator, + UndeclaredService, + SyncerWithoutLease, + SyncerWithLeaseButMethodNotListed, + OtherReposSpindle, + MemberOfAnotherPrivateRepo, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + NoLease, + Leased, +} + +impl Viewer { + fn all(phase: Phase, mode: Mode) -> Vec { + use Viewer::*; + let basic = [ + Anonymous, + GarbageToken, + WrongMethodToken, + ExpiredToken, + WrongAudienceToken, + Stranger, + StrangerWithRepoAudience, + UndeclaredService, + ]; + let seeded = [ + BlockedCollaborator, + RemovedCollaborator, + SyncerWithLeaseButMethodNotListed, + OtherReposSpindle, + MemberOfAnotherPrivateRepo, + ]; + let mut viewers = basic.to_vec(); + if mode == Mode::Full { + viewers.extend(seeded); + } + if phase == Phase::NoLease && mode != Mode::Warming { + viewers.push(SyncerWithoutLease); + } + viewers + } + + fn token(self, lxm: &str, target: &str) -> Option { + let knot = knot_audience(); + let as_account = |who: &AccountDid| Some(jwt(lxm, who, &knot, LIVE)); + match self { + Viewer::Anonymous => None, + Viewer::GarbageToken => Some("Bearer not.a.token".to_string()), + Viewer::WrongMethodToken => Some(jwt(NOT_A_ROUTE_LXM, &owner(), &knot, LIVE)), + Viewer::ExpiredToken => Some(jwt(lxm, &owner(), &knot, EXPIRED)), + Viewer::WrongAudienceToken => { + Some(jwt(lxm, &owner(), "did:web:elsewhere.example", LIVE)) + } + Viewer::Stranger => as_account(&common::stranger()), + Viewer::StrangerWithRepoAudience => Some(jwt(lxm, &common::stranger(), target, LIVE)), + Viewer::BlockedCollaborator => as_account(&blocked()), + Viewer::RemovedCollaborator => as_account(&removed()), + Viewer::UndeclaredService => as_account(&undeclared()), + Viewer::SyncerWithoutLease => as_account(&wide()), + Viewer::SyncerWithLeaseButMethodNotListed => as_account(&narrow()), + Viewer::OtherReposSpindle => as_account(&ci()), + Viewer::MemberOfAnotherPrivateRepo => as_account(&neighbour()), + } + } + + fn missing_answer(self) -> &'static [StatusCode] { + match self { + Viewer::Anonymous => &[StatusCode::UNAUTHORIZED, StatusCode::NOT_FOUND], + Viewer::GarbageToken + | Viewer::WrongMethodToken + | Viewer::ExpiredToken + | Viewer::WrongAudienceToken + | Viewer::SyncerWithLeaseButMethodNotListed => &[StatusCode::UNAUTHORIZED], + _ => &[StatusCode::NOT_FOUND], + } + } +} + +#[derive(Clone)] +struct Probe { + label: String, + method: &'static str, + path: String, + query: Vec<(String, String)>, + body: Vec, + content_type: Option<&'static str>, + control: bool, +} + +struct Form { + label: &'static str, + owner: String, + name_suffix: &'static str, + trailing_slash: bool, +} + +struct Target { + did: String, + name: String, +} + +impl Target { + fn of(did: &RepoDid, name: &str, suffix: &str) -> Self { + Self { + did: did.to_string(), + name: format!("{name}{suffix}"), + } + } + + fn swaps(&self) -> [(&str, &str); 2] { + [(&self.did, ""), (&self.name, "")] + } +} + +impl Probe { + fn uri(&self, form: &Form, target: &Target) -> String { + let path = self + .path + .replace("{owner}", &form.owner) + .replace("{did}", &target.did) + .replace("{name}", &target.name); + let path = if form.trailing_slash { + format!("{path}/") + } else { + path + }; + if self.query.is_empty() { + return path; + } + let query: Vec = self + .query + .iter() + .map(|(key, value)| { + let value = value.replace("{did}", &target.did); + format!("{key}={}", common::percent_encode(&value)) + }) + .collect(); + format!("{path}?{}", query.join("&")) + } +} + +fn forms_of(route: &RouteSpec) -> Vec
{ + let owner_did = owner().to_string(); + if !route.path.contains("{name}") { + return vec![Form { + label: "as is", + owner: owner_did, + name_suffix: "", + trailing_slash: false, + }]; + } + let form = |label, owner: &str, name_suffix, trailing_slash| Form { + label, + owner: owner.to_string(), + name_suffix, + trailing_slash, + }; + vec![ + form("owner did", &owner_did, "", false), + form("owner handle", HANDLE, "", false), + form(".git suffix", &owner_did, ".git", false), + form("trailing slash", &owner_did, "", true), + ] +} + +fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> { + list.iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() +} + +fn base_query(nsid: &str, facts: &Facts) -> Vec<(String, String)> { + let ticket_uri = format!("at://{{did}}/{TICKET}/{}", facts.ticket); + match nsid.rsplit('.').next().unwrap() { + "getTree" => pairs(&[("repo", "{did}"), ("ref", "main"), ("path", "src")]), + "listCommits" => pairs(&[("repo", "{did}"), ("ref", "main"), ("limit", "2")]), + "getBranch" => pairs(&[("repo", "{did}"), ("name", "main")]), + "getTag" => pairs(&[("repo", "{did}"), ("tag", "v1.0.0")]), + "getBlob" if nsid.starts_with("org.") => { + pairs(&[("repo", "{did}"), ("ref", "main"), ("path", "README.md")]) + } + "getEntry" => pairs(&[("repo", "{did}"), ("ref", "main"), ("path", "src/main.rs")]), + "getBlame" => pairs(&[("repo", "{did}"), ("ref", "main"), ("path", "README.md")]), + "getDiff" => pairs(&[ + ("repo", "{did}"), + ("base", facts.first.as_str()), + ("head", facts.head.as_str()), + ]), + "getArchive" => pairs(&[ + ("repo", "{did}"), + ("ref", "main"), + ("format", "tar.gz"), + ("prefix", "vault"), + ]), + "listLanguages" => pairs(&[("repo", "{did}"), ("ref", "main")]), + "getRecord" if nsid.starts_with("com.atproto.sync") => pairs(&[ + ("did", "{did}"), + ("collection", TICKET), + ("rkey", facts.ticket.as_str()), + ]), + "getRecord" => pairs(&[ + ("repo", "{did}"), + ("collection", TICKET), + ("rkey", facts.ticket.as_str()), + ]), + "getBlocks" => pairs(&[("did", "{did}"), ("cids", facts.blob.as_str())]), + "listRecords" => pairs(&[("repo", "{did}"), ("collection", TICKET)]), + "getBlob" => pairs(&[("did", "{did}"), ("cid", facts.blob.as_str())]), + "listRecordVersions" => pairs(&[("uri", ticket_uri.as_str())]), + "listRepoOps" => pairs(&[("space", "{did}")]), + "getRepo" | "getLatestCommit" | "getRepoStatus" | "listBlobs" => pairs(&[("did", "{did}")]), + _ => pairs(&[("repo", "{did}")]), + } +} + +fn get_probes(route: &RouteSpec, facts: &Facts) -> Vec { + let nsid = route.lxm.unwrap(); + let base = base_query(nsid, facts); + let anchors = ["repo", "did", "space"]; + let probe = |label: String, query: Vec<(String, String)>, control: bool| Probe { + label, + method: "GET", + path: route.path.to_string(), + query, + body: Vec::new(), + content_type: None, + control, + }; + let mut probes = vec![probe("valid".to_string(), base.clone(), true)]; + for (index, (key, _)) in base.iter().enumerate() { + if anchors.contains(&key.as_str()) { + continue; + } + let mut without = base.clone(); + without.remove(index); + probes.push(probe(format!("{key} missing"), without, false)); + for junk in ["nope", "../../etc/passwd", "", "\u{0}\u{7f}"] { + let mut query = base.clone(); + query[index].1 = junk.to_string(); + probes.push(probe(format!("{key}={junk:?}"), query, false)); + } + } + for (key, value) in [ + ("limit", "0"), + ("limit", "999999999"), + ("cursor", "\u{ff}bad-cursor"), + ] { + let mut query = base.clone(); + query.retain(|(existing, _)| existing != key); + query.push((key.to_string(), value.to_string())); + probes.push(probe(format!("{key}={value:?}"), query, false)); + } + probes +} + +fn merge_check_probes(route: &RouteSpec, facts: &Facts) -> Vec { + let probe = |label: &str, body: String, control: bool| Probe { + label: label.to_string(), + method: "POST", + path: route.path.to_string(), + query: Vec::new(), + body: body.into_bytes(), + content_type: Some("application/json"), + control, + }; + vec![ + probe( + "valid", + json!({ "repo": "{did}", "patch": facts.patch }).to_string(), + true, + ), + probe( + "patch missing", + json!({ "repo": "{did}" }).to_string(), + false, + ), + probe( + "patch is junk", + json!({ "repo": "{did}", "patch": "../nope" }).to_string(), + false, + ), + probe( + "branch is junk", + json!({ "repo": "{did}", "patch": facts.patch, "branch": "../x" }).to_string(), + false, + ), + probe( + "unknown field", + json!({ "repo": "{did}", "patch": facts.patch, "extra": 1 }).to_string(), + false, + ), + probe("not json", "{".to_string(), false), + ] +} + +fn pkt(line: &str) -> Vec { + format!("{:04x}{line}", line.len() + 4).into_bytes() +} + +fn git_probes(route: &RouteSpec, facts: &Facts) -> Vec { + let path = route.path.replace("{did}", "{owner}"); + let path = if route.path.contains("{name}") { + path + } else { + route.path.to_string() + }; + let probe = |label: &str, + query: Vec<(String, String)>, + body: Vec, + content_type: Option<&'static str>, + control: bool| Probe { + label: label.to_string(), + method: route.method, + path: path.clone(), + query, + body, + content_type, + control, + }; + if route.path.ends_with("/info/refs") { + return vec![ + probe( + "fetch advert", + pairs(&[("service", "git-upload-pack")]), + vec![], + None, + true, + ), + probe( + "push advert", + pairs(&[("service", "git-receive-pack")]), + vec![], + None, + false, + ), + probe("no service", vec![], vec![], None, false), + probe( + "bogus service", + pairs(&[("service", "git-bogus")]), + vec![], + None, + false, + ), + ]; + } + let mut want = pkt(&format!("want {}\n", facts.head)); + want.extend_from_slice(b"0000"); + want.extend_from_slice(b"0009done\n"); + let (valid, content_type): (Vec, &'static str) = if route.path.ends_with("/git-upload-pack") + { + (want, "application/x-git-upload-pack-request") + } else if route.path.ends_with("/git-upload-archive") { + ( + pkt("argument --format=tar\n"), + "application/x-git-upload-archive-request", + ) + } else { + (Vec::new(), "application/x-git-receive-pack-request") + }; + let control = route.path.ends_with("/git-upload-pack"); + vec![ + probe("valid", vec![], valid, Some(content_type), control), + probe("empty", vec![], vec![], Some(content_type), false), + probe( + "garbage", + vec![], + b"zzzz not a pkt line".to_vec(), + Some(content_type), + false, + ), + ] +} + +fn lfs_batch(operation: &str, oid: &str) -> Vec { + json!({ + "operation": operation, + "transfers": ["basic"], + "objects": [{ "oid": oid, "size": MEDIA.len() }], + "hash_algo": "sha256", + }) + .to_string() + .into_bytes() +} + +fn lfs_probes(route: &RouteSpec, facts: &Facts) -> Vec { + let named = route.path.contains("{name}"); + let base = match named { + true => route.path.replace("{did}", "{owner}"), + false => route.path.to_string(), + }; + let json_type = Some("application/vnd.git-lfs+json"); + let probe = |label: &str, + path: String, + body: Vec, + content_type: Option<&'static str>, + control: bool| Probe { + label: label.to_string(), + method: route.method, + path, + query: Vec::new(), + body, + content_type, + control, + }; + match (route.method, route.path.ends_with("/batch")) { + ("POST", _) => vec![ + probe( + "download", + base.clone(), + lfs_batch("download", &facts.object), + json_type, + true, + ), + probe( + "upload", + base.clone(), + lfs_batch("upload", &facts.object), + json_type, + false, + ), + probe("not json", base.clone(), b"{".to_vec(), json_type, false), + probe( + "no objects", + base, + b"{\"operation\":\"download\"}".to_vec(), + json_type, + false, + ), + ], + ("GET", _) => vec![ + probe( + "valid", + base.replace("{oid}", &facts.object), + vec![], + None, + true, + ), + probe( + "bad oid", + base.replace("{oid}", "nothex"), + vec![], + None, + false, + ), + ], + _ => vec![ + probe( + "valid", + base.replace("{oid}", &facts.object), + MEDIA.to_vec(), + None, + false, + ), + probe( + "empty", + base.replace("{oid}", &facts.object), + vec![], + None, + false, + ), + probe( + "bad oid", + base.replace("{oid}", "nothex"), + MEDIA.to_vec(), + None, + false, + ), + ], + } +} + +fn probes_of(route: &RouteSpec, facts: &Facts) -> Vec { + match route.kind { + RouteKind::Read if route.method == "POST" => merge_check_probes(route, facts), + RouteKind::Read => get_probes(route, facts), + RouteKind::Git => git_probes(route, facts), + RouteKind::Lfs => lfs_probes(route, facts), + other => panic!("{other:?} routes have no read probes"), + } +} + +fn next_peer() -> SocketAddr { + static NEXT: AtomicU32 = AtomicU32::new(0); + let n = NEXT.fetch_add(1, Ordering::Relaxed) % 90_000; + SocketAddr::new(IpAddr::V4(Ipv4Addr::from(0x0a00_0000 + n)), 4000) +} + +async fn send( + router: &Router, + probe: &Probe, + form: &Form, + target: &Target, + auth: Option, +) -> Answer { + let body = String::from_utf8_lossy(&probe.body).replace("{did}", &target.did); + let mut request = request( + probe.method, + &probe.uri(form, target), + auth.as_deref(), + probe.content_type, + body, + ); + request.extensions_mut().insert(ConnectInfo(next_peer())); + answer_from(router, request, &target.swaps(), NO_DATE).await +} + +#[derive(Default)] +struct Tally { + routes: BTreeSet<(&'static str, &'static str)>, + compared: usize, + viewers: BTreeSet, + exact: usize, + statuses: BTreeMap, +} + +fn is_read_surface(route: &&RouteSpec) -> bool { + matches!( + route.kind, + RouteKind::Read | RouteKind::Git | RouteKind::Lfs + ) +} + +async fn sweep(rig: &Rig, phase: Phase, mode: Mode, tally: &mut Tally) { + let hidden = &rig.hidden; + for route in ROUTES.iter().filter(is_read_surface) { + let lxm = route.lxm.unwrap(); + for probe in probes_of(route, &hidden.facts) { + for form in forms_of(route) { + let here = Target::of(&hidden.did, hidden.name, form.name_suffix); + let missing = Target::of(&ghost(), "ghost", form.name_suffix); + for viewer in Viewer::all(phase, mode) { + let by_path = route.path.contains("{name}"); + if viewer == Viewer::WrongAudienceToken && by_path { + continue; + } + let ask = |target: &Target| viewer.token(lxm, &target.did); + let private = send(&rig.router, &probe, &form, &here, ask(&here)).await; + let absent = send(&rig.router, &probe, &form, &missing, ask(&missing)).await; + let who = format!( + "{viewer:?} on {} {} ({}, {})", + route.method, route.path, probe.label, form.label + ); + assert_ne!(private.0, StatusCode::OK, "{who}"); + assert!( + private.0.is_client_error() || mode != Mode::Full, + "{who}: {private:?}" + ); + assert_eq!(private, absent, "{who} vs a missing repo"); + if probe.label == "valid" || probe.label.starts_with("fetch advert") { + if mode == Mode::Full && !form.trailing_slash { + assert!( + viewer.missing_answer().contains(&absent.0) + || (route.kind == RouteKind::Read + && absent.0 == StatusCode::BAD_REQUEST) + || (route.lxm == Some(PUSH) + && absent.0 == StatusCode::UNAUTHORIZED), + "{who}: the missing repo answers {}", + absent.0 + ); + } + tally.exact += 1; + } + *tally.statuses.entry(absent.0).or_default() += 1; + tally.viewers.insert(viewer); + tally.compared += 1; + tally.routes.insert((route.method, route.path)); + } + } + } + } +} + +async fn control( + rig: &Rig, + who: &AccountDid, + anonymous: bool, + site: &Site, +) -> BTreeSet<(&'static str, &'static str)> { + let mut answered = BTreeSet::new(); + for route in ROUTES.iter().filter(is_read_surface) { + let lxm = route.lxm.unwrap(); + for probe in probes_of(route, &site.facts) + .into_iter() + .filter(|probe| probe.control) + { + for form in forms_of(route).into_iter().filter(|form| { + let lfs_by_handle = route.kind == RouteKind::Lfs && form.label == "owner handle"; + !(form.trailing_slash || lfs_by_handle) + }) { + let target = Target::of(&site.did, site.name, form.name_suffix); + let auth = (!anonymous).then(|| jwt(lxm, who, &knot_audience(), LIVE)); + let answer = send(&rig.router, &probe, &form, &target, auth).await; + assert_eq!( + answer.0, + StatusCode::OK, + "{who} on {} {} ({}, {}): {}", + route.method, + route.path, + probe.label, + form.label, + answer.2 + ); + answered.insert((route.method, route.path)); + } + } + } + answered +} + +fn the_controlled_surface() -> BTreeSet<(&'static str, &'static str)> { + ROUTES + .iter() + .filter(is_read_surface) + .filter(|route| { + !route.path.ends_with("/git-receive-pack") + && !route.path.ends_with("/git-upload-archive") + && route.method != "PUT" + }) + .map(|route| (route.method, route.path)) + .collect() +} + +fn the_swept_surface() -> BTreeSet<(&'static str, &'static str)> { + ROUTES + .iter() + .filter(is_read_surface) + .map(|route| (route.method, route.path)) + .collect() +} + +#[test] +fn the_route_table_names_each_route_once() { + let unique: BTreeSet<_> = ROUTES + .iter() + .map(|route| (route.method, route.path)) + .collect(); + assert_eq!(unique.len(), ROUTES.len()); + let kinds: BTreeMap<_, usize> = ROUTES.iter().fold(BTreeMap::new(), |mut kinds, route| { + *kinds.entry(format!("{:?}", route.kind)).or_default() += 1; + kinds + }); + assert!( + kinds.len() == 6 && kinds.values().all(|count| *count > 0), + "every kind has routes: {kinds:?}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_private_repo_reads_like_a_missing_one_on_every_route_to_every_viewer_not_allowed() { + // writing tickets as the owner renews a lease so the no-lease pass needs a bare twin + let bare = rig(Mode::Full, Depth::Bare).await; + let mut tally = Tally::default(); + sweep(&bare, Phase::NoLease, Mode::Full, &mut tally).await; + assert_eq!( + bare.world + .state + .index + .lease_covers(&bare.hidden.did, bare.world.state.now()), + Resolved::Ready(false), + "nobody allowed read yet, and nobody denied created a lease" + ); + + let rig = rig(Mode::Full, Depth::Rich).await; + let now = rig.world.state.now(); + let public = control(&rig, &owner(), true, &rig.open).await; + assert_eq!( + public, + the_controlled_surface(), + "the public twin answers every probe anonymously" + ); + let owned = control(&rig, &owner(), false, &rig.hidden).await; + assert_eq!( + owned, + the_controlled_surface(), + "the owner reads every route" + ); + let helped = control(&rig, &helper(), false, &rig.hidden).await; + assert_eq!( + helped, + the_controlled_surface(), + "a collaborator reads every route" + ); + assert_eq!( + rig.world.state.index.lease_covers(&rig.hidden.did, now), + Resolved::Ready(true), + "the members' reads made a lease" + ); + + sweep(&rig, Phase::Leased, Mode::Full, &mut tally).await; + + assert_eq!(tally.routes, the_swept_surface()); + assert_eq!(tally.viewers.len(), 14, "every kind of viewer was tried"); + assert!(tally.exact > 0 && tally.compared > tally.exact); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_syncer_with_a_live_lease_is_the_one_outsider_that_reads_the_private_repo() { + let rig = rig(Mode::Full, Depth::Rich).await; + assert_eq!( + control(&rig, &owner(), false, &rig.hidden).await, + the_controlled_surface() + ); + let mut reads = BTreeSet::new(); + for route in ROUTES.iter().filter(|route| route.kind == RouteKind::Read) { + let lxm = route.lxm.unwrap(); + for probe in probes_of(route, &rig.hidden.facts) + .into_iter() + .filter(|probe| probe.control) + { + let form = &forms_of(route)[0]; + let target = Target::of(&rig.hidden.did, rig.hidden.name, ""); + let auth = Viewer::SyncerWithoutLease.token(lxm, &target.did); + let answer = send(&rig.router, &probe, form, &target, auth).await; + assert_eq!( + answer.0, + StatusCode::OK, + "{} {}: {}", + route.method, + route.path, + answer.2 + ); + reads.insert(route.path); + } + } + assert_eq!( + reads.len(), + ROUTES + .iter() + .filter(|route| route.kind == RouteKind::Read) + .count() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_warming_knot_answers_every_read_route_the_same_for_a_private_repo_and_a_missing_one() { + let rig = rig(Mode::Warming, Depth::Bare).await; + let mut tally = Tally::default(); + sweep(&rig, Phase::NoLease, Mode::Warming, &mut tally).await; + assert_eq!(tally.routes, the_swept_surface()); + assert!(tally.compared > 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_rebuilt_index_with_collaborators_still_unloaded_answers_like_a_missing_repo() { + let rig = rig(Mode::CollaboratorsUnloaded, Depth::Bare).await; + let mut tally = Tally::default(); + sweep( + &rig, + Phase::NoLease, + Mode::CollaboratorsUnloaded, + &mut tally, + ) + .await; + assert_eq!(tally.routes, the_swept_surface()); + rig.world + .state + .index + .ensure_collaborators(&rig.hidden.did) + .unwrap(); + let route = ROUTES + .iter() + .find(|route| route.path.ends_with("listRefs")) + .unwrap(); + let probe = probes_of(route, &rig.hidden.facts).remove(0); + let target = Target::of(&rig.hidden.did, "vault", ""); + let auth = Some(jwt( + "org.tangled.git.listRefs", + &owner(), + &knot_audience(), + LIVE, + )); + let answer = send(&rig.router, &probe, &forms_of(route)[0], &target, auth).await; + assert_eq!( + answer.0, + StatusCode::OK, + "once collaborators load the owner reads: {}", + answer.2 + ); +} + +fn audience_of(nsid: &str, repo: &RepoDid) -> String { + match nsid.rsplit('.').next().unwrap() { + "createRecord" | "putRecord" | "deleteRecord" | "mergePatch" | "mintToken" + | "uploadBlob" => repo.to_string(), + _ => knot_audience(), + } +} + +fn write_probe(route: &RouteSpec, facts: &Facts) -> Probe { + let record = json!({ + "$type": TICKET, "repo": "{did}", "title": "let me in", "body": "", + "state": "open", "createdAt": "2026-09-11T00:00:00Z", + }); + let body = match route.lxm.unwrap().rsplit('.').next().unwrap() { + "createRecord" => json!({ "repo": "{did}", "collection": TICKET, "record": record }), + "putRecord" => json!({ + "repo": "{did}", "collection": TICKET, "rkey": facts.ticket, "record": record, + "swapRecord": facts.ticket_cid, + }), + "deleteRecord" => json!({ + "repo": "{did}", "collection": TICKET, "rkey": facts.ticket, + "swapRecord": facts.ticket_cid, + }), + "mergePatch" => json!({ "repo": "{did}", "patch": facts.patch }), + "createRepo" => json!({ "name": "fresh" }), + "deleteRepo" => json!({ "repo": "{did}" }), + "reserveKey" => json!({ "repoDid": "{did}" }), + "mintToken" => json!({ + "repo": "{did}", "aud": knot_audience(), "lxm": FETCH, "exp": 2_000, + }), + "deleteBranch" => json!({ "repo": "{did}", "branch": "feature" }), + "forkSync" => json!({ "repo": "{did}", "branch": "main" }), + _ => serde_json::Value::Null, + }; + let raw = route.lxm.unwrap().ends_with("uploadBlob"); + Probe { + label: "write".to_string(), + method: "POST", + path: route.path.to_string(), + query: Vec::new(), + body: match (raw, body.is_null()) { + (true, _) => PNG.to_vec(), + (false, true) => Vec::new(), + (false, false) => body.to_string().into_bytes(), + }, + content_type: Some(if raw { "image/png" } else { "application/json" }), + control: false, + } +} + +fn spindle_write_status(lxm: &str) -> StatusCode { + match lxm.rsplit('.').next().unwrap() { + "renewLease" | "deleteRepo" | "receivePack" => StatusCode::OK, + "createRepo" | "reserveKey" => StatusCode::FORBIDDEN, + _ => StatusCode::NOT_FOUND, + } +} + +fn snapshot(world: &World, repos: &[&RepoDid]) -> Vec { + let mut seen: Vec = repos + .iter() + .map(|did| sh_git(&world.layout.repo_path(did).unwrap(), &["for-each-ref"])) + .collect(); + seen.push(sh_git(&world.state.meta_path, &["for-each-ref"])); + let mut objects: Vec = std::fs::read_dir(&world.lfs_dir) + .unwrap() + .map(|entry| entry.unwrap().path().display().to_string()) + .collect(); + objects.sort(); + seen.extend(objects); + seen +} + +#[tokio::test(flavor = "multi_thread")] +async fn syncers_and_spindles_change_nothing_through_any_write_route_of_a_private_repo() { + let rig = rig(Mode::Full, Depth::Rich).await; + let world = &rig.world; + assert_eq!( + control(&rig, &owner(), false, &rig.hidden).await, + the_controlled_surface() + ); + let now = world.state.now(); + assert_eq!( + world.state.index.lease_covers(&rig.hidden.did, now), + Resolved::Ready(true) + ); + assert!(world.state.index.take_unsaved_leases(now).is_some()); + world.sent(); + let stranger_probe_target = Target::of(&rig.hidden.did, "vault", ""); + let form = &forms_of(&ROUTES[0])[0]; + let mut answered = 0; + for (step, spindle) in [(0, None), (1, Some(ci())), (2, Some(wide()))] { + name_spindle(world, &rig.hidden.did, spindle.as_ref()); + let before = snapshot(world, &[&rig.hidden.did, &rig.open.did]); + for route in ROUTES.iter().filter(|route| route.kind == RouteKind::Write) { + let lxm = route.lxm.unwrap(); + let probe = write_probe(route, &rig.hidden.facts); + let token = + |who: &AccountDid| Some(jwt(lxm, who, &audience_of(lxm, &rig.hidden.did), LIVE)); + let who = match step { + 1 => ci(), + _ => wide(), + }; + let answer = send( + &rig.router, + &probe, + form, + &stranger_probe_target, + token(&who), + ) + .await; + let context = format!("step {step}, {who} on {lxm}: {answer:?}"); + if who == wide() { + assert_eq!(answer.0, StatusCode::UNAUTHORIZED, "{context}"); + assert!(answer.2.contains("method binding mismatch"), "{context}"); + } else { + let plain = send( + &rig.router, + &probe, + form, + &stranger_probe_target, + token(&common::stranger()), + ) + .await; + assert_eq!(answer, plain, "a spindle writes like a stranger: {context}"); + let pinned = spindle_write_status(lxm); + assert_eq!(answer.0, pinned, "{context}"); + let renews = ["renewLease", "deleteRepo", "receivePack"] + .iter() + .any(|name| lxm.ends_with(name)); + assert!(renews || answer.0.is_client_error(), "{context}"); + } + answered += 1; + } + assert_eq!( + before, + snapshot(world, &[&rig.hidden.did, &rig.open.did]), + "step {step}" + ); + } + let leased: Vec = world + .state + .index + .take_unsaved_leases(now) + .unwrap_or_default() + .into_iter() + .map(|record| record.account) + .collect(); + assert!( + !leased.contains(&wide()), + "the syncer never got a lease: {leased:?}" + ); + assert!(world.sent().is_empty()); + assert_eq!( + answered, + 3 * ROUTES + .iter() + .filter(|route| route.kind == RouteKind::Write) + .count() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_syncer_that_is_also_a_collaborator_cant_renew_or_read_its_way_to_a_lease() { + let world = World::declaring(syncers()); + let hidden = site(&world, "vault", Visibility::Private, Depth::Bare, true).await; + world.add_collaborator(&hidden.did, &wide(), &owner(), at(1_000)); + world.add_collaborator(&hidden.did, &helper(), &owner(), at(1_000)); + let router = router_for(&world); + let now = world.state.now(); + let form = &forms_of(&ROUTES[0])[0]; + let target = Target::of(&hidden.did, "vault", ""); + let renew = ROUTES + .iter() + .find(|route| route.path.ends_with("renewLease")) + .unwrap(); + let probe = write_probe(renew, &hidden.facts); + let lxm = renew.lxm.unwrap(); + + let refused = send( + &router, + &probe, + form, + &target, + Some(jwt(lxm, &wide(), &knot_audience(), LIVE)), + ) + .await; + assert_eq!(refused.0, StatusCode::UNAUTHORIZED, "{refused:?}"); + let list = ROUTES + .iter() + .find(|route| route.path.ends_with("listRefs")) + .unwrap(); + let read = probes_of(list, &hidden.facts).remove(0); + let denied = send( + &router, + &read, + form, + &target, + Viewer::SyncerWithoutLease.token(list.lxm.unwrap(), &target.did), + ) + .await; + assert_eq!(denied.0, StatusCode::NOT_FOUND, "{denied:?}"); + assert_eq!( + world.state.index.lease_covers(&hidden.did, now), + Resolved::Ready(false) + ); + assert_eq!(world.state.index.take_unsaved_leases(now), None); + + let renewed = send( + &router, + &probe, + form, + &target, + Some(jwt(lxm, &helper(), &knot_audience(), LIVE)), + ) + .await; + assert_eq!(renewed.0, StatusCode::OK, "{renewed:?}"); + assert_eq!( + world.state.index.lease_covers(&hidden.did, now), + Resolved::Ready(true) + ); +}