use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use sha2::{Digest, Sha256};
const REPO: &str = "alex28042/fundaia-apps";
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
pub trait Progress {
fn say(&self, message: &str);
}
pub struct SelfInstaller {
version: String,
target: &'static str,
executable: PathBuf,
}
impl SelfInstaller {
pub fn prepare(version: &str) -> Result<Self> {
let target = target_triple().ok_or_else(|| {
anyhow!(
"no hay binario publicado para {}-{}",
std::env::consts::ARCH,
std::env::consts::OS
)
})?;
let executable = std::env::current_exe()
.and_then(|path| path.canonicalize())
.context("no se ha podido localizar el propio ejecutable")?;
let directory = executable
.parent()
.ok_or_else(|| anyhow!("el ejecutable no está dentro de ningún directorio"))?;
let probe = directory.join(".fundaia-write-check");
std::fs::write(&probe, b"")
.with_context(|| format!("{} no es escribible", directory.display()))?;
let _ = std::fs::remove_file(&probe);
Ok(Self {
version: version.to_owned(),
target,
executable,
})
}
pub fn destination(&self) -> &Path {
&self.executable
}
pub async fn install(&self, progress: &dyn Progress) -> Result<()> {
let scratch =
std::env::temp_dir().join(format!("fundaia-{}-{}", self.version, std::process::id()));
std::fs::create_dir_all(&scratch)
.with_context(|| format!("no se ha podido crear {}", scratch.display()))?;
let outcome = self.take_a_route(&scratch, progress).await;
let _ = std::fs::remove_dir_all(&scratch);
outcome
}
async fn take_a_route(&self, scratch: &Path, progress: &dyn Progress) -> Result<()> {
let http = reqwest::Client::builder()
.timeout(DOWNLOAD_TIMEOUT)
.user_agent(concat!("fundaia/", env!("CARGO_PKG_VERSION")))
.build()?;
progress.say("descargando…");
let url = self.asset_url();
let Ok(archive) = fetch(&http, &url).await else {
progress.say("el binario publicado no está a la vista; compilando desde crates.io…");
return self.build_from_the_crate(progress).await;
};
let published = fetch(&http, &format!("{url}.sha256"))
.await
.context("la release no publica checksum para este binario")?;
let published = String::from_utf8(published).context("el checksum no es texto")?;
verify(&archive, &published)?;
let packed = scratch.join("fundaia.tar.gz");
std::fs::write(&packed, &archive).context("no se ha podido guardar la descarga")?;
unpack(&packed, scratch).await?;
let fresh = scratch.join("fundaia");
if !fresh.is_file() {
bail!("el archivo descargado no contiene fundaia");
}
self.swap(&fresh)
}
async fn build_from_the_crate(&self, progress: &dyn Progress) -> Result<()> {
let mut cargo = tokio::process::Command::new("cargo");
cargo
.arg("install")
.arg("fundaia")
.arg("--version")
.arg(&self.version)
.arg("--locked")
.arg("--force")
.arg("--quiet");
if let Some(root) = cargo_root(&self.executable) {
cargo.arg("--root").arg(root);
}
let built = cargo
.output()
.await
.context("no se ha podido ejecutar cargo — instálalo o actualiza a mano")?;
if !built.status.success() {
let complaint = String::from_utf8_lossy(&built.stderr);
bail!("cargo install ha fallado: {}", last_line(&complaint));
}
progress.say("compilada e instalada");
Ok(())
}
fn swap(&self, fresh: &Path) -> Result<()> {
let staged = self
.executable
.with_file_name(format!(".fundaia-{}.new", self.version));
std::fs::copy(fresh, &staged)
.with_context(|| format!("no se ha podido escribir en {}", staged.display()))?;
if let Err(error) = make_executable(&staged) {
let _ = std::fs::remove_file(&staged);
return Err(error);
}
if let Err(error) = std::fs::rename(&staged, &self.executable) {
let _ = std::fs::remove_file(&staged);
return Err(error).with_context(|| {
format!("no se ha podido reemplazar {}", self.executable.display())
});
}
Ok(())
}
fn asset_url(&self) -> String {
format!(
"https://github.com/{REPO}/releases/download/{tag}/{asset}",
tag = tag_of(&self.version),
asset = asset_name(&self.version, self.target),
)
}
}
async fn fetch(http: &reqwest::Client, url: &str) -> Result<Vec<u8>> {
let response = http.get(url).send().await?.error_for_status()?;
Ok(response.bytes().await?.to_vec())
}
async fn unpack(archive: &Path, into: &Path) -> Result<()> {
let status = tokio::process::Command::new("tar")
.arg("-xzf")
.arg(archive)
.arg("-C")
.arg(into)
.status()
.await
.context("no se ha podido ejecutar tar")?;
if !status.success() {
bail!("tar no ha podido descomprimir la descarga");
}
Ok(())
}
fn verify(archive: &[u8], published: &str) -> Result<()> {
let expected = expected_digest(published)
.ok_or_else(|| anyhow!("el checksum publicado está vacío o mal formado"))?;
let actual = digest_of(archive);
if actual != expected {
bail!("el checksum no coincide: la descarga se descarta");
}
Ok(())
}
fn expected_digest(published: &str) -> Option<String> {
let digest = published.split_whitespace().next()?;
if digest.len() != 64 || !digest.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(digest.to_ascii_lowercase())
}
fn digest_of(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
digest
.iter()
.fold(String::with_capacity(64), |mut text, byte| {
let _ = write!(text, "{byte:02x}");
text
})
}
fn tag_of(version: &str) -> String {
format!("cli-v{version}")
}
fn asset_name(version: &str, target: &str) -> String {
format!("fundaia-{tag}-{target}.tar.gz", tag = tag_of(version))
}
fn cargo_root(executable: &Path) -> Option<&Path> {
let directory = executable.parent()?;
if directory.file_name()? != "bin" {
return None;
}
directory.parent()
}
fn last_line(complaint: &str) -> &str {
complaint
.lines()
.map(str::trim)
.rfind(|line| !line.is_empty())
.unwrap_or("sin explicación")
}
fn target_triple() -> Option<&'static str> {
triple_for(std::env::consts::ARCH, std::env::consts::OS)
}
fn triple_for(arch: &str, os: &str) -> Option<&'static str> {
match (arch, os) {
("aarch64", "macos") => Some("aarch64-apple-darwin"),
("x86_64", "macos") => Some("x86_64-apple-darwin"),
("x86_64", "linux") => Some("x86_64-unknown-linux-gnu"),
("aarch64", "linux") => Some("aarch64-unknown-linux-gnu"),
_ => None,
}
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).with_context(|| {
format!(
"no se han podido ajustar los permisos de {}",
path.display()
)
})
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_should_name_the_asset_the_way_the_release_workflow_does() {
assert_eq!(
asset_name("0.4.0", "aarch64-apple-darwin"),
"fundaia-cli-v0.4.0-aarch64-apple-darwin.tar.gz",
);
}
#[test]
fn it_should_name_the_tag_the_way_the_release_workflow_does() {
assert_eq!(tag_of("0.4.0"), "cli-v0.4.0");
}
#[test]
fn it_should_know_the_binary_an_apple_silicon_mac_runs() {
assert_eq!(triple_for("aarch64", "macos"), Some("aarch64-apple-darwin"));
}
#[test]
fn it_should_know_the_binary_the_home_server_runs() {
assert_eq!(
triple_for("x86_64", "linux"),
Some("x86_64-unknown-linux-gnu")
);
}
#[test]
fn it_should_refuse_a_platform_nothing_is_published_for() {
assert!(triple_for("x86_64", "windows").is_none());
}
#[test]
fn it_should_read_the_digest_out_of_what_sha256sum_writes() {
let line = format!("{} fundaia.tar.gz", "a".repeat(64));
assert_eq!(expected_digest(&line), Some("a".repeat(64)));
}
#[test]
fn it_should_read_a_digest_that_arrived_on_its_own() {
assert_eq!(expected_digest(&"b".repeat(64)), Some("b".repeat(64)));
}
#[test]
fn it_should_read_an_uppercase_digest_as_the_same_digest() {
assert_eq!(expected_digest(&"A".repeat(64)), Some("a".repeat(64)));
}
#[test]
fn it_should_refuse_a_checksum_file_that_is_empty() {
assert!(expected_digest(" \n").is_none());
}
#[test]
fn it_should_refuse_a_digest_of_the_wrong_length() {
assert!(expected_digest("deadbeef fundaia.tar.gz").is_none());
}
#[test]
fn it_should_refuse_a_digest_that_is_not_hexadecimal() {
assert!(expected_digest(&"z".repeat(64)).is_none());
}
#[test]
fn it_should_hash_the_way_sha256sum_does() {
assert_eq!(
digest_of(b"fundaia"),
"2973701beace145a932d249fbf716f25802a722be3ca41476b59e666597a83d4"
);
}
#[test]
fn it_should_accept_an_archive_whose_digest_matches() {
let published = format!("{} fundaia.tar.gz", digest_of(b"fundaia"));
assert!(verify(b"fundaia", &published).is_ok());
}
#[test]
fn it_should_refuse_an_archive_whose_digest_does_not_match() {
let published = format!("{} fundaia.tar.gz", digest_of(b"fundaia"));
assert!(verify(b"something else entirely", &published).is_err());
}
#[test]
fn it_should_refuse_an_archive_with_no_checksum_at_all() {
assert!(verify(b"fundaia", "").is_err());
}
#[test]
fn it_should_point_cargo_at_the_prefix_the_binary_lives_under() {
assert_eq!(
cargo_root(Path::new("/home/alex/.local/bin/fundaia")),
Some(Path::new("/home/alex/.local")),
);
}
#[test]
fn it_should_let_cargo_decide_when_the_binary_is_not_in_a_bin_directory() {
assert!(cargo_root(Path::new("/opt/tools/fundaia")).is_none());
}
#[test]
fn it_should_report_what_cargo_complained_about_last() {
assert_eq!(
last_line(" Compiling fundaia\nerror: could not compile\n\n"),
"error: could not compile",
);
}
#[test]
fn it_should_say_something_when_cargo_failed_without_a_word() {
assert_eq!(last_line(" \n\n"), "sin explicación");
}
}