#![cfg_attr(not(engine), allow(dead_code))]
use std::io::Read;
use std::path::Path;
use sha2::{Digest, Sha256};
use super::Paths;
const PINNED: &str = include_str!("../../engine/SHA256SUMS");
pub static VERSION: std::sync::LazyLock<&'static str> = std::sync::LazyLock::new(|| {
lines()
.find_map(|line| line.strip_prefix("engine-v"))
.expect("engine/SHA256SUMS names its version")
});
fn lines() -> impl Iterator<Item = &'static str> {
PINNED
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
}
fn pinned(asset: &str) -> Option<String> {
lines().find_map(|line| {
let (digest, file) = line.split_once(char::is_whitespace)?;
(file.trim() == asset)
.then(|| parse_checksum(digest))
.flatten()
})
}
pub const ASSET: &str = if cfg!(windows) {
"ferrodock-engine-wsl-amd64.tar.zst"
} else if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"ferrodock-engine-arm64.tar.zst"
} else if cfg!(target_os = "macos") {
"ferrodock-engine-amd64.tar.zst"
} else if cfg!(target_arch = "aarch64") {
"ferrodock-engine-linux-arm64.tar.zst"
} else {
"ferrodock-engine-linux-amd64.tar.zst"
};
fn base_url() -> String {
format!(
"https://github.com/manimovassagh/Ferrodock/releases/download/engine-v{}",
*VERSION
)
}
pub fn parse_checksum(line: &str) -> Option<String> {
let digest = line.split_whitespace().next()?.to_lowercase();
(digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit())).then_some(digest)
}
fn numbers(version: &str) -> Option<Vec<u64>> {
version.trim().split('.').map(|n| n.parse().ok()).collect()
}
fn older(installed: &str, wanted: &str) -> bool {
match (numbers(installed), numbers(wanted)) {
(Some(installed), Some(wanted)) => installed < wanted,
(None, Some(_)) => true,
_ => false,
}
}
const UNRECORDED: &str = "0.2.0";
pub fn outdated(paths: &Paths) -> bool {
let installed =
std::fs::read_to_string(&paths.version).unwrap_or_else(|_| UNRECORDED.to_owned());
older(&installed, &VERSION)
}
pub fn record(paths: &Paths) -> Result<(), String> {
std::fs::write(&paths.version, *VERSION)
.map_err(|e| format!("{}: {e}", paths.version.display()))
}
pub fn fetch(mut progress: impl FnMut(u64, Option<u64>)) -> Result<Vec<u8>, String> {
let get = |url: &str| ureq::get(url).call().map_err(|e| format!("{url}: {e}"));
let expected = pinned(ASSET).ok_or_else(|| {
format!("This Ferrodock has no checksum for {ASSET}, so it can't check it.")
})?;
let mut response = get(&format!("{}/{ASSET}", base_url()))?;
let total = response.body().content_length();
let mut reader = response.body_mut().as_reader();
let mut archive = Vec::with_capacity(total.unwrap_or(0) as usize);
let mut hasher = Sha256::new();
let mut buffer = vec![0u8; 256 * 1024];
loop {
let n = reader.read(&mut buffer).map_err(|e| e.to_string())?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
archive.extend_from_slice(&buffer[..n]);
progress(archive.len() as u64, total);
}
let actual = ferrodock_core::hex(&hasher.finalize());
if actual != expected {
return Err(
"The engine download isn't the one this Ferrodock expects (checksum mismatch). \
Try again; if it keeps happening, please report it."
.into(),
);
}
Ok(archive)
}
#[cfg_attr(not(windows), allow(dead_code))]
pub fn decompress(archive: &[u8], path: &Path) -> Result<(), String> {
let mut decoder =
ruzstd::decoding::StreamingDecoder::new(archive).map_err(|e| e.to_string())?;
let mut file = std::fs::File::create(path).map_err(|e| format!("{}: {e}", path.display()))?;
std::io::copy(&mut decoder, &mut file)
.map(drop)
.map_err(|e| format!("Couldn't unpack the engine: {e}"))
}
#[cfg_attr(windows, allow(dead_code))]
pub fn unpack(archive: &[u8], dir: &Path, needs: &[&str]) -> Result<(), String> {
let staging = dir.with_extension("new");
let _ = std::fs::remove_dir_all(&staging);
std::fs::create_dir_all(&staging).map_err(|e| e.to_string())?;
let decoder = ruzstd::decoding::StreamingDecoder::new(archive).map_err(|e| e.to_string())?;
tar::Archive::new(decoder)
.unpack(&staging)
.map_err(|e| format!("Couldn't unpack the engine: {e}"))?;
for file in needs {
if !staging.join(file).is_file() {
return Err(format!("The engine image has no {file}"));
}
}
let _ = std::fs::remove_dir_all(dir);
std::fs::rename(&staging, dir).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_sha256sum_lines() {
let digest = "a".repeat(64);
assert_eq!(
parse_checksum(&format!("{digest} ferrodock-engine-arm64.tar.zst\n")),
Some(digest)
);
assert_eq!(parse_checksum("nope file"), None);
assert_eq!(parse_checksum(""), None);
}
#[test]
fn pins_every_platforms_image() {
assert_eq!(VERSION.split('.').count(), 3, "{}", *VERSION);
for arch in ["arm64", "amd64", "wsl-amd64", "linux-amd64", "linux-arm64"] {
let asset = format!("ferrodock-engine-{arch}.tar.zst");
assert!(pinned(&asset).is_some(), "no pinned checksum for {asset}");
}
assert!(pinned(ASSET).is_some());
assert_eq!(pinned("ferrodock-engine-arm64.tar.zst.sha256"), None);
}
#[test]
fn pins_a_published_version() {
let next = include_str!("../../engine/VERSION");
assert!(
!older(next, &VERSION),
"pinned {} is newer than {next}",
*VERSION
);
}
#[test]
fn updates_only_to_newer_images() {
assert!(older("0.1.9", "0.2.0"));
assert!(older("0.2.0", "0.10.0"));
assert!(!older("0.2.0\n", "0.2.0"));
assert!(!older("0.3.0", "0.2.0"));
assert!(older("", "0.2.0"));
assert!(!older("0.2.0", "garbled"));
let dir = std::env::temp_dir().join(format!("ferrodock-version-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let paths = Paths::under(dir.clone());
assert_eq!(outdated(&paths), older(UNRECORDED, &VERSION));
std::fs::write(&paths.version, "0.1.0").unwrap();
assert!(outdated(&paths));
record(&paths).unwrap();
assert!(!outdated(&paths));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn unpacks_the_image() {
let mut tarball = tar::Builder::new(Vec::new());
for (name, body) in [
("kernel", &b"kernel image"[..]),
("rootfs.img", b"root disk"),
] {
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tarball.append_data(&mut header, name, body).unwrap();
}
let archive = ruzstd::encoding::compress_to_vec(
tarball.into_inner().unwrap().as_slice(),
ruzstd::encoding::CompressionLevel::Fastest,
);
let dir = std::env::temp_dir().join(format!("ferrodock-engine-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("old"), "old").unwrap();
unpack(&archive, &dir, &["kernel", "rootfs.img"]).unwrap();
assert_eq!(std::fs::read(dir.join("kernel")).unwrap(), b"kernel image");
assert_eq!(std::fs::read(dir.join("rootfs.img")).unwrap(), b"root disk");
assert!(!dir.join("old").exists());
let tar = dir.with_extension("tar");
decompress(&archive, &tar).unwrap();
assert_eq!(
tar::Archive::new(std::fs::File::open(&tar).unwrap())
.entries()
.unwrap()
.count(),
2
);
std::fs::remove_file(&tar).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(
unpack(
&ruzstd::encoding::compress_to_vec(
&b""[..],
ruzstd::encoding::CompressionLevel::Fastest
),
&dir,
&["kernel"]
)
.is_err()
);
}
}