ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
426 lines
13 KiB
Rust
426 lines
13 KiB
Rust
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<u8>),
|
|
Redirect(String),
|
|
Stall(Arc<AtomicBool>),
|
|
}
|
|
|
|
fn hex_of(bytes: &[u8]) -> String {
|
|
Sha256::digest(bytes)
|
|
.iter()
|
|
.map(|b| format!("{b:02x}"))
|
|
.collect()
|
|
}
|
|
|
|
fn payload() -> Vec<u8> {
|
|
(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<T>(result: anyhow::Result<T>) -> 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}");
|
|
}
|
|
}
|