use std::collections::BTreeMap; use std::io::{Read as _, Write as _}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; use crate::config::ValidatedConfig; use crate::manifest::{BuildStats, GenerationManifest, StaticResponse}; #[test] fn wire_keys_encodes_user_segments_but_keeps_separators() { use super::wire_keys; // invariant: separator stays literal, user value gets form-encoded. let spellings = wire_keys("r_001|refs/heads/main"); assert_eq!(spellings[0], "r_001|refs/heads/main"); assert_eq!(spellings[1], "r_001|refs%2Fheads%2Fmain"); // invariant: path segments escape spaces and ':' too. let spellings = wire_keys("c_ab12|docs/a b.md|false"); assert_eq!(spellings[1], "c_ab12|docs%2Fa%20b.md|false"); // invariant: already-encoded keys are left alone. assert_eq!( wire_keys("r_001|refs%2Fheads%2Fmain"), vec!["r_001|refs%2Fheads%2Fmain".to_string()] ); // invariant: non-composite keys are returned as-is (repo aliases encode themselves). assert_eq!(wire_keys("did:plc:abc"), vec!["did:plc:abc".to_string()]); } struct Nginx { binary: PathBuf, config: PathBuf, child: Child, } impl Drop for Nginx { fn drop(&mut self) { let _ = Command::new(&self.binary) .args(["-s", "quit", "-c"]) .arg(&self.config) .stdout(Stdio::null()) .stderr(Stdio::null()) .status(); let deadline = Instant::now() + Duration::from_secs(1); while Instant::now() < deadline { if matches!(self.child.try_wait(), Ok(Some(_))) { return; } thread::sleep(Duration::from_millis(10)); } let _ = self.child.kill(); let _ = self.child.wait(); } } fn response(body_hash: &str) -> StaticResponse { StaticResponse { body_hash: body_hash.into(), status: 200, content_type: "text/plain; charset=utf-8".into(), headers: Vec::new(), cas_key: body_hash.into(), } } fn write_response(generation: &Path, body_hash: &str, body: &str) { std::fs::write(generation.join("www/_responses").join(body_hash), body).unwrap(); } fn get(port: u16, target: &str) -> (String, String) { let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); write!( stream, "GET {target} HTTP/1.1\r\nHost: knot.test\r\nConnection: close\r\n\r\n" ) .unwrap(); let mut response = String::new(); stream.read_to_string(&mut response).unwrap(); let (head, body) = response.split_once("\r\n\r\n").unwrap(); assert!(head.starts_with("HTTP/1.1 200 "), "{response}"); assert!( head.lines() .any(|line| line.eq_ignore_ascii_case("content-type: text/plain; charset=utf-8")), "{response}" ); (head.into(), body.into()) } #[test] #[ignore = "requires nginx; set NGINX_BINARY"] fn emitted_config_passes_nginx_test_and_serves_routes() { let nginx_binary = PathBuf::from( std::env::var_os("NGINX_BINARY") .expect("NGINX_BINARY must name the real nginx binary for this ignored test"), ); let temp = tempfile::tempdir().unwrap(); let generation = temp.path().join("generation"); std::fs::create_dir_all(generation.join("www/_responses")).unwrap(); let config_path = temp.path().join("knot-static.toml"); std::fs::write( &config_path, r#" [knot] hostname = "knot.test" owner = "did:plc:owner" [repo] scan_path = "repos" [secrets] sealed_key_file = "sealed-keys" [output] root = "output" "#, ) .unwrap(); let config = ValidatedConfig::load(&config_path).unwrap(); let routes = BTreeMap::from([ ("did.json".into(), response("did")), ("version".into(), response("ver")), ("owner".into(), response("own")), ("health".into(), response("hlt")), ( "sh.tangled.repo.describeRepo?repoDid=did:plc:repo".into(), response("dsc"), ), ( "sh.tangled.repo.tag?repo=did:plc:repo&tag=v1".into(), response("tag"), ), ]); let manifest = GenerationManifest { generation_id: "test".into(), generator_profile: "test".into(), conf_template: "test".into(), knot_did: "did:web:knot.test".into(), hostname: "knot.test".into(), owner: "did:plc:owner".into(), static_version: "test".into(), capabilities: Vec::new(), profile: serde_json::json!({}), repos: Vec::new(), routes, sections: BTreeMap::new(), section_routes: BTreeMap::new(), section_objects: BTreeMap::new(), repo_aliases: BTreeMap::from([ ("did:plc:repo".into(), "r0".into()), ("did:plc:owner/repo".into(), "r0".into()), ]), ref_aliases: BTreeMap::new(), stats: BuildStats::default(), }; for (hash, body) in [ ("did", "did body"), ("ver", "version body"), ("own", "owner body"), ("hlt", "health body"), ("dsc", "describe body"), ("tag", "tag body"), ] { write_response(&generation, hash, body); } // invariant: smart upload-pack artifacts captured under smart/ directory. let git_dir = generation.join("www/_git/r0/smart"); std::fs::create_dir_all(&git_dir).unwrap(); std::fs::write( git_dir.join("advertisement"), b"001e# service=git-upload-pack\n0000ADVERTISED", ) .unwrap(); std::fs::write(git_dir.join("upload-pack"), b"0008NAK\nPACKDATA").unwrap(); super::emit(&config, &manifest, &generation).unwrap(); let emitted_config = generation.join("nginx/knot-static.conf"); let port = TcpListener::bind(("127.0.0.1", 0)) .unwrap() .local_addr() .unwrap() .port(); let text = std::fs::read_to_string(&emitted_config) .unwrap() .replace("listen 8080;", &format!("listen 127.0.0.1:{port};")); std::fs::write(&emitted_config, text).unwrap(); let check = Command::new(&nginx_binary) .args(["-t", "-c"]) .arg(&emitted_config) .output() .unwrap(); assert!( check.status.success(), "nginx -t failed:\nstdout:\n{}\nstderr:\n{}", String::from_utf8_lossy(&check.stdout), String::from_utf8_lossy(&check.stderr) ); let child = Command::new(&nginx_binary) .args(["-c"]) .arg(&emitted_config) .args(["-g", "daemon off;"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .unwrap(); let mut nginx = Nginx { binary: nginx_binary, config: emitted_config, child, }; let deadline = Instant::now() + Duration::from_secs(2); loop { if TcpStream::connect(("127.0.0.1", port)).is_ok() { break; } if let Some(status) = nginx.child.try_wait().unwrap() { panic!( "nginx exited during startup with {status}; error log:\n{}", std::fs::read_to_string(generation.join("nginx/error.log")).unwrap_or_default() ); } assert!( Instant::now() < deadline, "nginx did not start in two seconds" ); thread::sleep(Duration::from_millis(10)); } for (target, expected) in [ ("/.well-known/did.json", "did body"), ("/xrpc/sh.tangled.knot.version", "version body"), ("/xrpc/sh.tangled.owner", "owner body"), ("/xrpc/_health", "health body"), ( "/xrpc/sh.tangled.repo.describeRepo?repoDid=did:plc:repo", "describe body", ), ( "/xrpc/sh.tangled.repo.describeRepo?repoDid=did%3Aplc%3Arepo", "describe body", ), ( "/xrpc/sh.tangled.repo.describeRepo?repo=did:plc:owner/repo", "describe body", ), ( "/xrpc/sh.tangled.repo.tag?repo=did:plc:owner/repo&tag=v1", "tag body", ), ] { let (_, body) = get(port, target); assert_eq!(body, expected, "GET {target}"); } // invariant: smart upload-pack negotiation (knotmirror + modern git clients). let (status, head, body) = raw_request( port, "GET /did:plc:repo/info/refs?service=git-upload-pack HTTP/1.1\r\n", ); assert_eq!(status, "HTTP/1.1 200 ", "info/refs advertisement"); assert!( head.to_ascii_lowercase() .contains("content-type: application/x-git-upload-pack-advertisement"), "wrong info/refs content type: {head}" ); assert_eq!(body, "001e# service=git-upload-pack\n0000ADVERTISED"); let (status, head, body) = raw_request( port, "POST /did:plc:repo/git-upload-pack HTTP/1.1\r\nContent-Length: 4\r\n\r\nbody", ); assert_eq!(status, "HTTP/1.1 200 ", "upload-pack POST"); assert!( head.to_ascii_lowercase() .contains("content-type: application/x-git-upload-pack-result"), "wrong upload-pack content type: {head}" ); assert_eq!(body, "0008NAK\nPACKDATA"); for refused in [ "GET /did:plc:repo/info/refs HTTP/1.1\r\n", "GET /did:plc:repo/info/refs?service=git-receive-pack HTTP/1.1\r\n", ] { let (status, _, _) = raw_request(port, refused); assert_ne!(status, "HTTP/1.1 200 ", "{refused} must be refused"); } } /// Raw HTTP/1.1 request; returns (status line, head, body). fn raw_request(port: u16, request_head: &str) -> (String, String, String) { let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); write!( stream, "{request_head}Host: knot.test\r\nConnection: close\r\n\r\n" ) .unwrap(); let mut response = Vec::new(); stream.read_to_end(&mut response).unwrap(); let text = String::from_utf8_lossy(&response); let (head, body) = text.split_once("\r\n\r\n").unwrap(); let status = head.lines().next().unwrap_or_default(); (format!("{status} "), head.to_string(), body.to_string()) }