diff --git a/knot2/crates/knot-sim/tests/common/mod.rs b/knot2/crates/knot-sim/tests/common/mod.rs index b12c29da2..3520a5366 100644 --- a/knot2/crates/knot-sim/tests/common/mod.rs +++ b/knot2/crates/knot-sim/tests/common/mod.rs @@ -330,6 +330,171 @@ pub fn spawn_worker(test: &str, envs: &[(&str, &str)]) -> std::process::Child { command.spawn().expect("spawn chaos worker") } +pub type H3Answer = (StatusCode, Vec<(String, String)>, Vec); + +pub struct H3Session { + sender: h3::client::SendRequest, + quic: quinn::Connection, + drive: JoinHandle<()>, +} + +impl H3Session { + async fn over(connection: quinn::Connection) -> Self { + let quic = connection.clone(); + let (mut driver, sender) = h3::client::new(h3_quinn::Connection::new(connection)) + .await + .unwrap(); + let drive = tokio::spawn(async move { + let _ = std::future::poll_fn(|cx| driver.poll_close(cx)).await; + }); + Self { + sender, + quic, + drive, + } + } + + pub async fn full_handshake(edge: &Edge) -> Self { + let connection = edge + .client + .connect(edge.addr, "localhost") + .unwrap() + .await + .unwrap(); + Self::over(connection).await + } + + pub async fn send( + &mut self, + method: Method, + uri: &str, + headers: &[(&str, &str)], + body: Option, + ) -> H3Answer { + let request = headers + .iter() + .fold( + http::Request::builder().method(method).uri(uri), + |builder, (name, value)| builder.header(*name, *value), + ) + .body(()) + .unwrap(); + let mut stream = self.sender.send_request(request).await.unwrap(); + if let Some(body) = body { + stream.send_data(body).await.unwrap(); + } + finish_request(&mut stream).await; + let response = stream.recv_response().await.unwrap(); + let status = response.status(); + let mut answer_headers: Vec<(String, String)> = response + .headers() + .iter() + .filter(|(name, _)| *name != http::header::DATE) + .map(|(name, value)| (name.to_string(), value.to_str().unwrap().to_string())) + .collect(); + answer_headers.sort(); + (status, answer_headers, drain(&mut stream).await) + } + + pub fn close(self) { + self.quic.close(0u32.into(), b"done"); + self.drive.abort(); + } +} + +// rustls's memory cache never returns the tickets quinn stores in it +#[derive(Debug, Default)] +pub struct TicketStore { + tickets: std::sync::Mutex>, +} + +impl rustls::client::ClientSessionStore for TicketStore { + fn set_kx_hint(&self, _: ServerName<'static>, _: rustls::NamedGroup) {} + + fn kx_hint(&self, _: &ServerName<'_>) -> Option { + None + } + + fn set_tls12_session( + &self, + _: ServerName<'static>, + _: rustls::client::Tls12ClientSessionValue, + ) { + } + + fn tls12_session(&self, _: &ServerName<'_>) -> Option { + None + } + + fn remove_tls12_session(&self, _: &ServerName<'static>) {} + + fn insert_tls13_ticket( + &self, + _: ServerName<'static>, + value: rustls::client::Tls13ClientSessionValue, + ) { + self.tickets.lock().unwrap().push(value); + } + + fn take_tls13_ticket( + &self, + _: &ServerName<'static>, + ) -> Option { + self.tickets.lock().unwrap().pop() + } +} + +pub struct ZeroRttClient { + endpoint: quinn::Endpoint, +} + +impl ZeroRttClient { + pub fn new() -> Self { + let mut crypto = + rustls::ClientConfig::builder_with_provider(Arc::new(aws_lc_rs::default_provider())) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyServerCert)) + .with_no_client_auth(); + crypto.alpn_protocols = vec![b"h3".to_vec()]; + crypto.resumption = rustls::client::Resumption::store(Arc::new(TicketStore::default())); + crypto.enable_early_data = true; + let quic = quinn::crypto::rustls::QuicClientConfig::try_from(crypto).unwrap(); + let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap(); + endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic))); + Self { endpoint } + } + + pub async fn learn_a_ticket(&self, edge: &Edge, uri: &str) { + let connection = self + .endpoint + .connect(edge.addr, "localhost") + .unwrap() + .await + .unwrap(); + let mut session = H3Session::over(connection).await; + let (status, _, _) = session.send(Method::GET, uri, &[], None).await; + assert!(status.is_success(), "the ticket connection is served"); + session.close(); + } + + // a connection that couldn't start early still gets a fresh ticket for the retry + pub async fn resume(&self, edge: &Edge) -> (H3Session, quinn::ZeroRttAccepted) { + for _ in 0..4 { + let connecting = self.endpoint.connect(edge.addr, "localhost").unwrap(); + match connecting.into_0rtt() { + Ok((connection, accepted)) => return (H3Session::over(connection).await, accepted), + Err(connecting) => { + let connection = connecting.await.unwrap(); + connection.close(0u32.into(), b"no ticket yet"); + } + } + } + panic!("four connections in a row left no ticket that allows early data"); + } +} + pub const PINNED_DATE: &str = "2026-06-20T12:00:00+00:00"; pub fn git(cwd: &Path, args: &[&str]) -> String { diff --git a/knot2/crates/knot-sim/tests/h3_private.rs b/knot2/crates/knot-sim/tests/h3_private.rs new file mode 100644 index 000000000..9b11ff379 --- /dev/null +++ b/knot2/crates/knot-sim/tests/h3_private.rs @@ -0,0 +1,730 @@ +mod common; + +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::Arc; + +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use bytes::Bytes; +use common::{ + Edge, H3Answer, H3Session, ZeroRttClient, extract_pack, fetch_body, git, index_pack, object_set, +}; +use http::{Method, StatusCode}; +use knot_atproto::Atproto; +use knot_cob::{CobHome, CobStore}; +use knot_cobs::{BlocklistChange, CollaboratorsChange, Grant, Registration, RegistryChange}; +use knot_edge::RequiresFullHandshake; +use knot_git::{Layout, Repo}; +use knot_runtime::{ + FakeHttp, HttpResponse, K256Signer, ManualClock, OsEntropy, Signer, UnixMicros, +}; +use knot_secrets::{MasterKey, SealedStore}; +use knot_types::{ + AccountDid, AdmissionPolicy, AuthorName, Did, Email, KnotHostname, KnotId, KnotServiceUrl, + OwnerDid, RepoDid, RepoName, RepoRkey, UnixSeconds, Visibility, +}; +use tempfile::TempDir; +use url::Url; + +const KNOT_DID: &str = "did:web:nel.pet"; +const OWNER: &str = "did:plc:nel"; +const MEMBER: &str = "did:plc:olaren"; +const BLOCKED: &str = "did:plc:teq"; +const STRANGER: &str = "did:plc:stranger"; +const OPEN_NAME: &str = "anemone"; +const HIDDEN_NAME: &str = "barnacle"; +const FETCH: &str = "org.tangled.git.uploadPack"; +const PUSH: &str = "org.tangled.git.receivePack"; + +fn account(did: &str) -> AccountDid { + AccountDid::new(did).unwrap() +} + +fn actor_signer() -> K256Signer { + K256Signer::generate(&knot_runtime::SeededEntropy::new(9)) +} + +fn token(iss: &str, aud: &str, lxm: &str) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"ES256K","typ":"JWT"}"#); + let claims = serde_json::json!({ + "iss": iss, + "aud": aud, + "exp": 1_001, + "iat": 999, + "jti": format!("{iss}-{aud}-{lxm}"), + "lxm": lxm, + }); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let signing_input = format!("{header}.{payload}"); + let signature = actor_signer().sign(signing_input.as_bytes()); + format!( + "{signing_input}.{}", + URL_SAFE_NO_PAD.encode(signature.as_bytes()) + ) +} + +fn basic(iss: &str, aud: &str, lxm: &str) -> String { + let raw = base64::engine::general_purpose::STANDARD + .encode(format!("x-tangled-token:{}", token(iss, aud, lxm))); + format!("Basic {raw}") +} + +// the repo also holds cob objects on refs/cobs and upload-pack never serves those +fn reachable_from_main(dir: &Path) -> BTreeSet { + git(dir, &["rev-list", "--objects", "main"]) + .lines() + .filter_map(|line| line.split_whitespace().next()) + .map(str::to_string) + .collect() +} + +fn seed(work: &Path, bare: &Path, name: &str) -> String { + std::fs::create_dir_all(work).unwrap(); + git(work, &["init", "-q", "-b", "main"]); + std::fs::write(work.join("README.md"), format!("{name} over h3\n")).unwrap(); + git(work, &["add", "-A"]); + git(work, &["commit", "-q", "-m", "c1"]); + std::fs::write(work.join("extra.txt"), "a second object\n").unwrap(); + git(work, &["add", "-A"]); + git(work, &["commit", "-q", "-m", "c2"]); + git(work, &["push", "-q", bare.to_str().unwrap(), "main"]); + git(bare, &["symbolic-ref", "HEAD", "refs/heads/main"]); + git(work, &["rev-parse", "HEAD"]) +} + +struct Repos { + did: RepoDid, + tip: String, + objects: BTreeSet, +} + +struct Cove { + _scan: TempDir, + _certdir: TempDir, + edge: Edge, + hidden: Repos, + open: Repos, + ghost: RepoDid, +} + +fn fake_http() -> impl knot_runtime::HttpTransport { + let knot_pds = KnotServiceUrl::new("https://127.0.0.1:1").unwrap(); + FakeHttp::new(move |request: &knot_runtime::HttpRequest| { + let host = request.url.host_str().unwrap_or_default().to_string(); + let path = request.url.path().to_string(); + let body = if host == "plc.directory" && request.method == http::Method::POST { + b"{}".to_vec() + } else if host == "plc.directory" && path.starts_with("/did:") { + let did = Did::new_owned(path.trim_start_matches('/')).expect("the plc path is a DID"); + common::did_document(&actor_signer(), &did, knot_pds.as_str()) + } else { + return Ok(HttpResponse { + status: StatusCode::NOT_FOUND, + headers: http::HeaderMap::new(), + body: Bytes::new(), + }); + }; + Ok(HttpResponse { + status: StatusCode::OK, + headers: http::HeaderMap::new(), + body: Bytes::from(body), + }) + }) +} + +async fn cove() -> Cove { + let scan = tempfile::tempdir().unwrap(); + let knot = KnotId::new(KNOT_DID).unwrap(); + let layout = Layout::new(scan.path().join("repos")) + .reserving_meta(&knot) + .unwrap(); + layout.bootstrap_meta(&knot).unwrap(); + let meta_path = layout.meta_path(&knot).unwrap(); + let open_did = RepoDid::new("did:plc:openfixture").unwrap(); + let hidden_did = RepoDid::new("did:plc:hiddenfixture").unwrap(); + layout.create(&open_did).unwrap(); + let git_hidden = layout.create(&hidden_did).unwrap(); + + let secrets = Arc::new( + SealedStore::open( + scan.path().join("keys.sealed"), + &MasterKey::new([7u8; 32]).unwrap(), + Box::new(OsEntropy), + ) + .unwrap(), + ); + secrets.ensure(&knot).unwrap(); + let knot_signer = secrets.signer(&knot).unwrap(); + + let at = UnixSeconds::new; + let meta = Repo::open(&meta_path).unwrap(); + let store = CobStore::new(&meta); + let registration = |name: &str, repo: &RepoDid, visibility| Registration { + owner: OwnerDid::new(OWNER).unwrap(), + rkey: RepoRkey::new(name).unwrap(), + name: RepoName::new(name).unwrap(), + repo: repo.clone(), + created_at: at(1), + policy: knot_types::ContributionPolicy::default(), + visibility, + spindle: None, + }; + let registry = store + .create( + &CobHome::from(&knot), + &RegistryChange::Register(registration(OPEN_NAME, &open_did, Visibility::Public)), + &knot_signer, + at(1), + ) + .unwrap(); + store + .update( + &CobHome::from(&knot), + registry.object, + &RegistryChange::Register(registration(HIDDEN_NAME, &hidden_did, Visibility::Private)), + &knot_signer, + at(2), + ) + .unwrap(); + let grant = |subject: &str| Grant { + subject: account(subject), + added_by: account(OWNER), + created_at: at(1), + }; + store + .create( + &CobHome::from(&knot), + &BlocklistChange::Add(grant(BLOCKED)), + &knot_signer, + at(1), + ) + .unwrap(); + let roster = CobStore::new(&git_hidden); + let created = roster + .create( + &CobHome::from(&hidden_did), + &CollaboratorsChange::Add(grant(MEMBER)), + &knot_signer, + at(1), + ) + .unwrap(); + roster + .update( + &CobHome::from(&hidden_did), + created.object, + &CollaboratorsChange::Add(grant(BLOCKED)), + &knot_signer, + at(1), + ) + .unwrap(); + + let seeded = |did: &RepoDid, name: &str| { + let bare = layout.repo_path(did).unwrap(); + let tip = seed(&scan.path().join(format!("work_{name}")), &bare, name); + Repos { + did: did.clone(), + tip, + objects: reachable_from_main(&bare), + } + }; + let hidden = seeded(&hidden_did, HIDDEN_NAME); + let open = seeded(&open_did, OPEN_NAME); + + let (index, social) = knot_index::Index::new(meta_path.clone(), layout.clone()).split_social(); + let index = Arc::new(index); + index.rebuild().unwrap(); + index.warm_collaborators(); + + let atproto = Arc::new(Atproto::new( + fake_http(), + ManualClock::new(UnixMicros::new(1_000_000_000)), + knot.clone(), + knot_atproto::PlcDirectory::new(Url::parse("https://plc.directory/").unwrap()).unwrap(), + )); + let (imports, _jobs) = knot_xrpc::imports::ImportQueue::channel(); + let xrpc_state = Arc::new(knot_xrpc::XrpcState { + imports, + ci_logs: None, + repos: knot_acl::Repos::new(layout.clone()), + index: Arc::clone(&index), + social: knot_acl::Social::new(social), + atproto, + secrets, + entropy: Arc::new(OsEntropy), + invite_codes: Default::default(), + admins: std::collections::BTreeSet::from([account(OWNER)]), + admission: AdmissionPolicy::Closed, + contribution_policy: knot_types::ContributionPolicy::Anyone, + writes: Arc::new(knot_resource::SubjectLimiter::new( + knot_resource::RateLimit { + burst: knot_xrpc::Burst::literal(1_000), + refill: knot_xrpc::RefillMicros::literal(1_000), + }, + )), + social_budget: knot_resource::SocialBudgetBytes::new(1 << 28), + knot_repo: knot_xrpc::knot_repo(&knot).unwrap(), + knot_did: knot, + knot_hostname: KnotHostname::new("nel.pet").unwrap(), + meta_path, + knot_service_url: KnotServiceUrl::new("http://127.0.0.1:1").unwrap(), + limiter: Arc::new(knot_xrpc::PreAuthLimiter::default()), + cob_locks: Arc::new(knot_xrpc::CobLocks::default()), + reservations: Arc::new(knot_xrpc::Reservations::new( + knot_xrpc::ReservationTtl::new(1_000_000), + knot_xrpc::PerActorQuota::new(16), + knot_xrpc::GlobalQuota::new(16), + )), + proxy_trust: knot_types::ProxyTrust::default(), + committer: knot_xrpc::Committer { + name: AuthorName::new("Tangled"), + email: Email::new("noreply@tangled.sh"), + }, + byte_limits: knot_xrpc::ByteLimits { + pack: knot_xrpc::MaxWireBytes::new(1 << 30), + ..knot_xrpc::ByteLimits::default() + }, + budgets: knot_xrpc::Budgets::default(), + git_http: Arc::new(FakeHttp::new(|_request: &knot_runtime::HttpRequest| { + Err(knot_runtime::NetworkError::Connect( + "no remote upstream is served here".to_string(), + )) + })), + pack_limits: knot_pack::PackLimits::default(), + service_owner: account(OWNER), + legacy_admin_secret: None, + firehose: Arc::new(knot_events::EventLog::new( + ManualClock::new(UnixMicros::new(1_000_000_000)), + knot_events::ReplayBounds::new( + knot_events::ReplayEvents::new(64).unwrap(), + knot_events::ReplayBytes::new(16 << 20).unwrap(), + ), + )), + subscriber_gate: Arc::new(knot_events::SubscriberGate::new( + knot_events::GlobalSubscriberLimit::new(16), + knot_events::PerPeerSubscriberLimit::new(4), + )), + maintenance: knot_maintenance::MaintenanceHandle::disabled(), + appview: knot_types::AppviewEndpoint::new("https://tangled.test").unwrap(), + slots: knot_resource::Slots::testing(8), + lfs: None, + blobs: Default::default(), + catalog: Arc::new(knot_messages::Catalog::defaults()), + rkeys: Default::default(), + syncers: Default::default(), + notify: knot_xrpc::notify::NotifyQueue::channel(&Default::default()).0, + }); + + let resolver = knot_pack::index_resolver(Arc::clone(&index)); + let advertiser = knot_xrpc::receive_advertiser(Arc::clone(&xrpc_state)); + let certdir = tempfile::tempdir().unwrap(); + let edge = common::serve_edge(certdir.path(), || { + let (write_routes, advertisement) = knot_pack::edge_routes(knot_pack::EdgeConfig { + receive: Some(Arc::clone(&advertiser)), + authenticator: Some(knot_xrpc::read_authenticator(Arc::clone(&xrpc_state))), + pack_slots: knot_resource::PackSlots::new(4), + ..knot_pack::EdgeConfig::serving( + knot_acl::Repos::new(layout.clone()), + Arc::clone(&resolver), + Arc::new(knot_runtime::SystemClock), + ) + }); + let app = RequiresFullHandshake::new( + write_routes.merge(knot_xrpc::router(Arc::clone(&xrpc_state))), + ); + (app, advertisement) + }) + .await; + + Cove { + _scan: scan, + _certdir: certdir, + edge, + hidden, + open, + ghost: RepoDid::new("did:plc:ghostsfixture").unwrap(), + } +} + +impl Cove { + async fn session(&self) -> H3Session { + let mut session = H3Session::full_handshake(&self.edge).await; + let (status, _, _) = session + .send( + Method::GET, + &self.advert(&self.open.did, "git-upload-pack"), + &[], + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "the public warmup read"); + session + } + + fn advert(&self, did: &RepoDid, service: &str) -> String { + format!("https://localhost/{did}/info/refs?service={service}") + } +} + +#[derive(Clone, Copy, Debug)] +enum Address { + Did, + OwnerName, +} + +struct Target { + did: RepoDid, + name: String, +} + +impl Address { + fn path(self, target: &Target) -> String { + match self { + Address::Did => format!("/{}", target.did), + Address::OwnerName => format!("/{OWNER}/{}", target.name), + } + } + + fn audience(self, target: &Target) -> String { + match self { + Address::Did => target.did.to_string(), + Address::OwnerName => KNOT_DID.to_string(), + } + } +} + +enum Viewer { + Anonymous, + Signed(&'static str, &'static str), +} + +impl Viewer { + fn authorization(&self, address: Address, target: &Target) -> Option { + match self { + Viewer::Anonymous => None, + Viewer::Signed(iss, lxm) => Some(basic(iss, &address.audience(target), lxm)), + } + } +} + +fn normalised(answer: H3Answer, targets: &[&Target]) -> H3Answer { + let mut body = String::from_utf8_lossy(&answer.2).into_owned(); + for target in targets { + body = body + .replace(target.did.as_str(), "") + .replace(&target.name, ""); + } + (answer.0, answer.1, body.into_bytes()) +} + +async fn ask( + session: &mut H3Session, + method: &Method, + address: Address, + target: &Target, + route: &str, + viewer: &Viewer, + tip: &str, +) -> H3Answer { + let uri = format!("https://localhost{}{route}", address.path(target)); + let authorization = viewer.authorization(address, target); + let mut headers = vec![("git-protocol", "version=2")]; + if let Some(authorization) = &authorization { + headers.push(("authorization", authorization)); + } + let body = match method { + &Method::POST => { + headers.push(("content-type", "application/x-git-upload-pack-request")); + Some(fetch_body(tip)) + } + _ => None, + }; + session.send(method.clone(), &uri, &headers, body).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn members_clone_a_private_repo_over_h3_and_get_exactly_the_bares_objects() { + let cove = cove().await; + let target = Target { + did: cove.hidden.did.clone(), + name: HIDDEN_NAME.into(), + }; + + let mut cloned = 0; + for address in [Address::Did, Address::OwnerName] { + for member in [OWNER, MEMBER] { + let mut session = cove.session().await; + let viewer = Viewer::Signed(member, FETCH); + let (status, _, advert) = ask( + &mut session, + &Method::GET, + address, + &target, + "/info/refs?service=git-upload-pack", + &viewer, + &cove.hidden.tip, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "{member} reads the advertisement via {address:?}" + ); + assert!( + String::from_utf8_lossy(&advert).contains("version 2"), + "the advertisement speaks protocol v2" + ); + let (status, _, response) = ask( + &mut session, + &Method::POST, + address, + &target, + "/git-upload-pack", + &viewer, + &cove.hidden.tip, + ) + .await; + assert_eq!(status, StatusCode::OK, "{member} fetches via {address:?}"); + session.close(); + + let clonedir = tempfile::tempdir().unwrap(); + git(clonedir.path(), &["init", "-q"]); + index_pack(clonedir.path(), &extract_pack(&response)); + assert_eq!( + object_set(clonedir.path()), + cove.hidden.objects, + "{member} via {address:?} gets the private repo's whole object set" + ); + cloned += 1; + } + } + assert_eq!(cloned, 4); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_private_repo_over_h3_answers_everyone_not_allowed_like_a_missing_repo() { + let cove = cove().await; + let hidden = Target { + did: cove.hidden.did.clone(), + name: HIDDEN_NAME.into(), + }; + let ghost = Target { + did: cove.ghost.clone(), + name: "phantoms".into(), + }; + + let routes = [ + (Method::GET, "/info/refs?service=git-upload-pack"), + (Method::GET, "/info/refs?service=git-receive-pack"), + (Method::POST, "/git-upload-pack"), + (Method::POST, "/git-upload-archive"), + (Method::POST, "/git-receive-pack"), + ]; + let viewers = [ + ("anonymous", Viewer::Anonymous, StatusCode::UNAUTHORIZED), + ( + "stranger", + Viewer::Signed(STRANGER, PUSH), + StatusCode::NOT_FOUND, + ), + ( + "blocked collaborator", + Viewer::Signed(BLOCKED, PUSH), + StatusCode::NOT_FOUND, + ), + ( + "blocked collaborator fetching", + Viewer::Signed(BLOCKED, FETCH), + StatusCode::NOT_FOUND, + ), + ]; + + let mut session = cove.session().await; + let mut compared = 0; + for address in [Address::Did, Address::OwnerName] { + for (method, route) in &routes { + for (who, viewer, status) in &viewers { + if matches!(viewer, Viewer::Signed(_, FETCH)) && route.contains("receive-pack") { + continue; + } + compared += 1; + let tip = &cove.hidden.tip; + let missing = ask(&mut session, method, address, &ghost, route, viewer, tip).await; + let receive = route.contains("receive-pack"); + assert!( + missing.0 == *status || (receive && missing.0 == StatusCode::UNAUTHORIZED), + "{who} on a missing repo {method} {route} via {address:?} answers {}", + missing.0 + ); + let denied = ask(&mut session, method, address, &hidden, route, viewer, tip).await; + assert_eq!( + normalised(denied, &[&hidden, &ghost]), + normalised(missing, &[&hidden, &ghost]), + "{who} on {method} {route} via {address:?} vs a missing repo" + ); + } + } + } + session.close(); + assert_eq!(compared, 2 * (routes.len() * viewers.len() - 2)); + + let mut member = cove.session().await; + let (status, _, _) = ask( + &mut member, + &Method::GET, + Address::OwnerName, + &hidden, + "/info/refs?service=git-upload-pack", + &Viewer::Signed(OWNER, FETCH), + &cove.hidden.tip, + ) + .await; + member.close(); + assert_eq!( + status, + StatusCode::OK, + "the positive control is a member read" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn early_data_with_a_credential_is_refused_with_425_while_the_same_request_after_the_handshake_is_served() + { + let cove = cove().await; + let hidden = Target { + did: cove.hidden.did.clone(), + name: HIDDEN_NAME.into(), + }; + let ghost = Target { + did: cove.ghost.clone(), + name: "phantoms".into(), + }; + let client = ZeroRttClient::new(); + let ticket_uri = cove.advert(&cove.open.did, "git-upload-pack"); + let advert = "/info/refs?service=git-upload-pack"; + + let members = [ + ("owner", Viewer::Signed(OWNER, FETCH)), + ("collaborator", Viewer::Signed(MEMBER, FETCH)), + ("stranger", Viewer::Signed(STRANGER, FETCH)), + ]; + let mut early_answers = 0; + for address in [Address::Did, Address::OwnerName] { + for (who, viewer) in &members { + for target in [&hidden, &ghost] { + client.learn_a_ticket(&cove.edge, &ticket_uri).await; + let (mut session, accepted) = client.resume(&cove.edge).await; + let answer = ask( + &mut session, + &Method::GET, + address, + target, + advert, + viewer, + &cove.hidden.tip, + ) + .await; + assert!(accepted.await, "the server took the request as 0-RTT data"); + session.close(); + assert_eq!( + answer.0, + StatusCode::TOO_EARLY, + "{who} with a credential in early data on {} via {address:?}", + target.name + ); + early_answers += 1; + } + } + } + + client.learn_a_ticket(&cove.edge, &ticket_uri).await; + let (mut session, accepted) = client.resume(&cove.edge).await; + let open = Target { + did: cove.open.did.clone(), + name: OPEN_NAME.into(), + }; + let (status, _, _) = ask( + &mut session, + &Method::GET, + Address::Did, + &open, + advert, + &Viewer::Signed(OWNER, FETCH), + &cove.hidden.tip, + ) + .await; + assert!(accepted.await); + session.close(); + assert_eq!( + status, + StatusCode::TOO_EARLY, + "a credential in early data is refused even for a public repo" + ); + + let mut anonymous = Vec::new(); + for target in [&hidden, &ghost] { + client.learn_a_ticket(&cove.edge, &ticket_uri).await; + let (mut session, accepted) = client.resume(&cove.edge).await; + let answer = ask( + &mut session, + &Method::GET, + Address::Did, + target, + advert, + &Viewer::Anonymous, + &cove.hidden.tip, + ) + .await; + assert!(accepted.await); + session.close(); + anonymous.push(normalised(answer, &[&hidden, &ghost])); + } + assert_eq!(anonymous[0].0, StatusCode::TOO_EARLY); + assert_eq!( + anonymous[0], anonymous[1], + "anonymous early data can't tell a private repo from a missing one" + ); + + client.learn_a_ticket(&cove.edge, &ticket_uri).await; + let (mut session, accepted) = client.resume(&cove.edge).await; + let (status, _, _) = ask( + &mut session, + &Method::POST, + Address::Did, + &hidden, + "/git-upload-pack", + &Viewer::Signed(OWNER, FETCH), + &cove.hidden.tip, + ) + .await; + assert!(accepted.await); + session.close(); + assert_eq!( + status, + StatusCode::TOO_EARLY, + "a post never rides early data" + ); + + let mut after = cove.session().await; + for address in [Address::Did, Address::OwnerName] { + for (who, viewer) in &members[..2] { + let (status, _, _) = ask( + &mut after, + &Method::GET, + address, + &hidden, + advert, + viewer, + &cove.hidden.tip, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "the {who}'s same request after the handshake via {address:?}" + ); + } + } + after.close(); + assert_eq!(early_answers, 12); +}