use std::{ fs, io::{Read, Write}, net::{TcpListener, TcpStream}, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, thread, time::{Duration, Instant}, }; use ely_platform::{ AppId, logging, update::{Check, IDLE_TIMEOUT, Release, check, download, platform, verify_file}, }; use semver::Version; use sha2::{Digest, Sha256}; #[derive(Clone)] enum Reply { Body(Vec), Redirect(String), Stall(Arc), } fn hex_of(bytes: &[u8]) -> String { Sha256::digest(bytes) .iter() .map(|b| format!("{b:02x}")) .collect() } fn payload() -> Vec { (0..300_000u32).map(|n| (n % 251) as u8).collect() } fn respond(mut stream: TcpStream, routes: &[(String, Reply)]) { let mut request = Vec::new(); let mut byte = [0u8; 1]; while !request.ends_with(b"\r\n\r\n") { if stream.read(&mut byte).unwrap() == 0 { return; } request.push(byte[0]); } let line = String::from_utf8_lossy(&request); let path = line.split_whitespace().nth(1).unwrap_or("/").to_string(); let reply = routes .iter() .find(|(route, _)| *route == path) .map(|(_, reply)| reply.clone()); let bytes = match reply { Some(Reply::Body(body)) => { let head = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ); [head.into_bytes(), body].concat() } Some(Reply::Redirect(to)) => format!( "HTTP/1.1 302 Found\r\nLocation: {to}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" ) .into_bytes(), Some(Reply::Stall(closed)) => { let _ = stream.write_all( b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n", ); let _ = stream.write_all(&[7u8; 1000]); stream .set_read_timeout(Some(Duration::from_secs(20))) .unwrap(); let seen = stream.read(&mut [0u8; 1]); if matches!(seen, Ok(0)) || seen.is_err_and(|error| error.kind() == std::io::ErrorKind::ConnectionReset) { closed.store(true, Ordering::Relaxed); } return; } None => { b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() } }; let _ = stream.write_all(&bytes); } /// Serves routes locally; `make` gets the base URL. fn serve(make: impl FnOnce(&str) -> Vec<(String, Reply)>) -> String { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); let routes = make(&base); thread::spawn(move || { for stream in listener.incoming() { let routes = routes.clone(); let stream = stream.unwrap(); thread::spawn(move || respond(stream, &routes)); } }); base } fn manifest(version: &str, key: &str, base: &str, sha: &str) -> Reply { Reply::Body( format!(r#"{{"version":"{version}","assets":{{"{key}":{{"url":"{base}/ElyDoc.bin","sha256":"{sha}"}}}}}}"#) .into_bytes(), ) } fn release(base: &str, sha: String) -> Release { Release { version: Version::new(0, 2, 0), url: format!("{base}/ElyDoc.bin"), sha256: sha, } } fn error_text(result: anyhow::Result) -> String { format!("{:#}", result.err().expect("an error")) } #[test] fn a_newer_release_for_this_platform_is_offered_and_an_equal_or_older_one_is_not() { let sha = hex_of(&payload()).to_uppercase(); let base = serve(|base| { vec![( "/latest.json".into(), manifest("0.2.0", &platform(), base, &sha), )] }); let url = format!("{base}/latest.json"); let found = check(&Version::new(0, 1, 0), &url).unwrap(); assert_eq!(found, Check::Available(release(&base, sha.to_lowercase()))); assert_eq!(check(&Version::new(0, 2, 0), &url).unwrap(), Check::Current); assert_eq!(check(&Version::new(0, 3, 0), &url).unwrap(), Check::Current); } #[test] fn a_manifest_without_this_platform_a_bad_manifest_and_a_missing_one_all_fail_loudly() { let base = serve(|base| { vec![ ( "/other.json".into(), manifest("0.2.0", "plan9-mips", base, "00"), ), ("/bad.json".into(), Reply::Body(b"{not json".to_vec())), ] }); let current = Version::new(0, 1, 0); assert!( error_text(check(¤t, &format!("{base}/other.json"))).contains("has no build for") ); assert!(error_text(check(¤t, &format!("{base}/bad.json"))).contains("not valid")); assert!(error_text(check(¤t, &format!("{base}/none.json"))).contains("cannot fetch")); } #[test] fn plain_http_to_other_hosts_and_credentials_in_the_url_are_refused_before_any_request() { let current = Version::new(0, 1, 0); assert!(error_text(check(¤t, "http://example.com/latest.json")).contains("not https")); let sneaky = "http://127.0.0.1:1@192.168.31.221:9/latest.json"; assert!(error_text(check(¤t, sneaky)).contains("carries credentials")); } #[test] fn a_redirect_is_followed_on_this_machine_and_refused_when_it_leaves_for_plain_http() { let sha = hex_of(&payload()); let base = serve(|base| { vec![ ( "/latest.json".into(), manifest("0.2.0", &platform(), base, &sha), ), ("/first".into(), Reply::Redirect("/second".into())), ("/second".into(), Reply::Redirect("/latest.json".into())), ( "/leave".into(), Reply::Redirect("http://example.com/latest.json".into()), ), ] }); let current = Version::new(0, 1, 0); let followed = check(¤t, &format!("{base}/first")).unwrap(); assert!(matches!(followed, Check::Available(_)), "{followed:?}"); assert!(error_text(check(¤t, &format!("{base}/leave"))).contains("not https")); } #[test] fn a_download_is_verified_reported_and_moved_into_place() { let body = payload(); let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); let dir = tempfile::tempdir().unwrap(); let mut seen = Vec::new(); let path = download( &release(&base, hex_of(&body)), dir.path(), &AtomicBool::new(false), IDLE_TIMEOUT, &mut |done, total| { seen.push((done, total)); }, ) .unwrap() .expect("not cancelled"); assert_eq!(path, dir.path().join("ElyDoc.bin")); assert_eq!(fs::read(&path).unwrap(), body); assert!(!dir.path().join("ElyDoc.bin.part").exists()); assert_eq!( seen.last().copied(), Some((body.len() as u64, Some(body.len() as u64))) ); assert!( seen.windows(2).all(|pair| pair[0].0 < pair[1].0), "progress went backwards" ); verify_file(&path, &hex_of(&body)).unwrap(); } #[test] fn a_wrong_hash_leaves_no_file_behind() { let body = payload(); let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); let dir = tempfile::tempdir().unwrap(); let result = download( &release(&base, hex_of(b"other")), dir.path(), &AtomicBool::new(false), IDLE_TIMEOUT, &mut |_, _| {}, ); assert!(error_text(result).contains("the release says")); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); } #[cfg(unix)] #[test] fn the_download_error_survives_a_failed_cleanup() { use std::os::unix::fs::PermissionsExt; let body = payload(); let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); let dir = tempfile::tempdir().unwrap(); let lock = |mode| fs::set_permissions(dir.path(), fs::Permissions::from_mode(mode)).unwrap(); let mut locked = false; let result = download( &release(&base, hex_of(b"other")), dir.path(), &AtomicBool::new(false), IDLE_TIMEOUT, &mut |_, _| { if !locked { lock(0o555); locked = true; } }, ); let still_writable = fs::File::create(dir.path().join("probe")).is_ok(); lock(0o755); if still_writable { println!("SKIP: permissions do not bind this user"); return; } assert!(error_text(result).contains("the release says")); } fn closed_within(flag: &AtomicBool, limit: Duration) -> bool { let started = Instant::now(); while started.elapsed() < limit { if flag.load(Ordering::Relaxed) { return true; } thread::sleep(Duration::from_millis(50)); } false } #[test] fn a_stalled_download_fails_and_frees_its_socket() { let closed = Arc::new(AtomicBool::new(false)); let base = serve({ let closed = closed.clone(); move |_| vec![("/ElyDoc.bin".into(), Reply::Stall(closed))] }); let dir = tempfile::tempdir().unwrap(); let stalled = download( &release(&base, hex_of(b"x")), dir.path(), &AtomicBool::new(false), Duration::from_millis(600), &mut |_, _| {}, ); let text = error_text(stalled); assert!(text.contains("stalled"), "{text}"); assert!( closed_within(&closed, Duration::from_secs(5)), "the client kept the socket open" ); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); } #[test] fn a_cancel_is_answered_at_once_and_the_socket_is_freed_within_the_idle_time() { let closed = Arc::new(AtomicBool::new(false)); let base = serve({ let closed = closed.clone(); move |_| vec![("/ElyDoc.bin".into(), Reply::Stall(closed))] }); let dir = tempfile::tempdir().unwrap(); let cancel = AtomicBool::new(false); let started = Instant::now(); let outcome = thread::scope(|scope| { scope.spawn(|| { thread::sleep(Duration::from_millis(300)); cancel.store(true, Ordering::Relaxed); }); download( &release(&base, hex_of(b"x")), dir.path(), &cancel, Duration::from_secs(1), &mut |_, _| {}, ) }); assert_eq!(outcome.unwrap(), None); assert!( started.elapsed() < Duration::from_secs(1), "the cancel waited for the socket" ); assert!( closed_within(&closed, Duration::from_secs(5)), "the client kept the socket open" ); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); } #[test] fn a_cancelled_download_returns_none_and_leaves_no_file_behind() { let body = payload(); let base = serve(|_| vec![("/ElyDoc.bin".into(), Reply::Body(body.clone()))]); let dir = tempfile::tempdir().unwrap(); let cancel = AtomicBool::new(false); let outcome = download( &release(&base, hex_of(&body)), dir.path(), &cancel, IDLE_TIMEOUT, &mut |_, _| { cancel.store(true, Ordering::Relaxed); }, ) .unwrap(); assert_eq!(outcome, None); assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 0); } #[test] fn a_file_the_user_brought_is_checked_against_its_published_hash() { let dir = tempfile::tempdir().unwrap(); let file = dir.path().join("ElyDoc.dmg"); fs::write(&file, b"installer bytes").unwrap(); verify_file(&file, &hex_of(b"installer bytes").to_uppercase()).unwrap(); assert!( error_text(verify_file(&file, &hex_of(b"tampered bytes"))).contains("the release says") ); assert!(error_text(verify_file(&dir.path().join("none"), "00")).contains("cannot open")); } #[test] fn checks_and_downloads_are_logged_with_their_outcome() { let logs = tempfile::tempdir().unwrap(); logging::init_in(AppId::Doc, logs.path()).unwrap(); let body = payload(); let sha = hex_of(&body); let base = serve(|base| { vec![ ( "/latest.json".into(), manifest("0.2.0", &platform(), base, &sha), ), ("/ElyDoc.bin".into(), Reply::Body(body.clone())), ] }); let dir = tempfile::tempdir().unwrap(); let cancel = AtomicBool::new(false); check(&Version::new(0, 1, 0), &format!("{base}/latest.json")).unwrap(); check(&Version::new(0, 1, 0), &format!("{base}/none.json")).unwrap_err(); download( &release(&base, sha), dir.path(), &cancel, IDLE_TIMEOUT, &mut |_, _| { cancel.store(true, Ordering::Relaxed); }, ) .unwrap(); log::logger().flush(); let text = fs::read_to_string(logs.path().join("ely.log")).unwrap(); for line in [ "update check: 127.0.0.1 offers 0.2.0", "update check: 127.0.0.1 failed", "update download: 0.2.0 cancelled", ] { assert!(text.contains(line), "no '{line}' in:\n{text}"); } }