use super::refs::{Ref, binary_name_of};
use super::release::{
checksum_sidecar, download_bytes, extract_binary, latest_release, pick_asset, platform_target,
release_by_tag, sha256_hex, verify_checksum,
};
use super::store::now_iso;
use super::{Manager, ManagerError, Receipt, Result};
use std::path::Path;
impl Manager {
pub fn install(&self, r: &Ref, force: bool) -> Result<Receipt> {
if let Some(url) = &r.tarball {
return self.install_tarball(r, url, force);
}
if let Some(existing) = self.receipt(&r.name)
&& existing.tag == r.tag.clone().unwrap_or_default()
&& !force
&& existing.source == r.repo
{
return Err(ManagerError::User(format!(
"{} {} is already installed (use --force to reinstall)",
r.name, existing.tag
)));
}
let timer = crate::provider::ui::Timer::start();
let ui = crate::provider::ui::Ui::new();
ui.step("Resolving", &r.repo);
let release = match &r.tag {
Some(tag) => release_by_tag(&r.repo, tag)?,
None => latest_release(&r.repo)?,
};
ui.done(
"Resolved",
&format!("{repo} @ {tag}", repo = r.repo, tag = release.tag_name),
);
let target = platform_target();
let asset = pick_asset(&release, target)?;
let sidecar = checksum_sidecar(&release, asset)?;
let spinner = ui.spinner(&format!("Downloading {}", asset.name));
let tarball = download_bytes(&asset.browser_download_url)?;
drop(spinner);
ui.step("Verifying", "sha256 checksum");
verify_checksum(&tarball, &sidecar.browser_download_url)?;
ui.done("Verified", &format!("sha256 ok ({})", asset.name));
ui.step("Extracting", &binary_name_of(r));
let binary_name = format!("rootle-{}", r.name);
let bytes = extract_binary(&tarball, &binary_name)?;
ui.done("Extracted", &binary_name);
let vdir = self.version_dir(&r.name, &release.tag_name);
std::fs::create_dir_all(&vdir)?;
let bin = vdir.join(&binary_name);
std::fs::write(&bin, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
}
let receipt = Receipt {
name: r.name.clone(),
source: r.repo.clone(),
tag: release.tag_name.clone(),
sha256: sha256_hex(&tarball),
pinned: r.tag.is_some(),
installed_at: now_iso(),
latest_tag: Some(release.tag_name.clone()),
};
self.write_receipt(&receipt)?;
self.point_current(&r.name, &release.tag_name)?;
ui.summary("Installed", &r.name, &release.tag_name, timer.elapsed());
ui.note(&format!(
"you are trusting {repo} — run `rootle provider use {name}` to activate",
repo = r.repo,
name = r.name
));
Ok(receipt)
}
fn install_tarball(&self, r: &Ref, url: &str, force: bool) -> Result<Receipt> {
let file = url.rsplit('/').next().unwrap_or(url);
if let Some(existing) = self.receipt(&r.name)
&& existing.source == url
&& !force
{
return Err(ManagerError::User(format!(
"{} is already installed from this URL (use --force to reinstall)",
r.name
)));
}
let timer = crate::provider::ui::Timer::start();
let ui = crate::provider::ui::Ui::new();
let spinner = ui.spinner(&format!("Downloading {file}"));
let tarball = download_bytes(url)?;
drop(spinner);
ui.step("Verifying", "sha256 checksum");
verify_checksum(&tarball, &format!("{url}.sha256"))?;
ui.done("Verified", &format!("sha256 ok ({file})"));
ui.step("Extracting", &binary_name_of(r));
let binary_name = format!("rootle-{}", r.name);
let bytes = extract_binary(&tarball, &binary_name)?;
ui.done("Extracted", &binary_name);
let tag = r
.tag
.clone()
.unwrap_or_else(|| sha256_hex(&tarball)[..12].to_string());
let vdir = self.version_dir(&r.name, &tag);
std::fs::create_dir_all(&vdir)?;
let bin = vdir.join(&binary_name);
std::fs::write(&bin, bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))?;
}
let receipt = Receipt {
name: r.name.clone(),
source: url.to_string(),
tag: tag.clone(),
sha256: sha256_hex(&tarball),
pinned: true, installed_at: now_iso(),
latest_tag: None,
};
self.write_receipt(&receipt)?;
self.point_current(&r.name, &tag)?;
ui.summary("Installed", &r.name, &tag, timer.elapsed());
ui.note(&format!(
"you are trusting {url} — install-and-pin: `update`/`upgrade` do not track \
plain-HTTP sources; upgrades come from whatever deployed it"
));
Ok(receipt)
}
pub fn install_path(&self, name: &str, path: &Path) -> Result<Receipt> {
if !path.is_file() {
return Err(ManagerError::User(format!(
"{} is not a file",
path.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
}
let vdir = self.version_dir(name, "local");
std::fs::create_dir_all(&vdir)?;
let bin = vdir.join(format!("rootle-{name}"));
#[cfg(unix)]
std::os::unix::fs::symlink(path, &bin)
.or_else(|_| std::fs::copy(path, &bin).map(|_| ()))
.map_err(ManagerError::Io)?;
#[cfg(not(unix))]
std::fs::copy(path, &bin)?;
let receipt = Receipt {
name: name.into(),
source: path.display().to_string(),
tag: "local".into(),
sha256: String::new(),
pinned: true, installed_at: now_iso(),
latest_tag: None,
};
self.write_receipt(&receipt)?;
self.point_current(name, "local")?;
println!("{name} installed (local: {path})", path = path.display());
Ok(receipt)
}
pub fn update(&self, name: Option<&str>) -> Result<Vec<(String, String, String)>> {
let mut stale = Vec::new();
for receipt in self.receipts() {
if let Some(want) = name
&& receipt.name != want
{
continue;
}
if receipt.pinned || !tracks_releases(&receipt.source) {
continue;
}
match latest_release(&receipt.source) {
Ok(rel) => {
let latest = rel.tag_name;
if latest != receipt.tag {
stale.push((receipt.name.clone(), receipt.tag.clone(), latest.clone()));
}
let mut updated = receipt.clone();
updated.latest_tag = Some(latest);
self.write_receipt(&updated)?;
}
Err(e) => eprintln!("{}: {e}", receipt.name),
}
}
Ok(stale)
}
pub fn upgrade(&self, name: Option<&str>, dry_run: bool, force: bool) -> Result<()> {
for receipt in self.receipts() {
if let Some(want) = name
&& receipt.name != want
{
continue;
}
if !tracks_releases(&receipt.source) {
println!(
"{}: {} source is install-and-pin, not tracked by upgrade",
receipt.name, receipt.source
);
continue;
}
if receipt.pinned && !force {
println!("{}: pinned at {} (use --force)", receipt.name, receipt.tag);
continue;
}
let latest = match &receipt.latest_tag {
Some(t) => t.clone(),
None => latest_release(&receipt.source)?.tag_name,
};
if latest == receipt.tag {
println!("{}: {} is current", receipt.name, receipt.tag);
continue;
}
if dry_run {
println!("{}: {} → {}", receipt.name, receipt.tag, latest);
continue;
}
let r = Ref {
repo: receipt.source.clone(),
name: receipt.name.clone(),
tag: None,
tarball: None,
};
self.install(&r, true)?;
}
Ok(())
}
}
fn tracks_releases(source: &str) -> bool {
!source.contains("://") && source.matches('/').count() == 1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_releases_api_sources_are_tracked() {
assert!(tracks_releases("rootledev/rootle-gitlab"));
assert!(!tracks_releases(
"https://artifacts.corp.example/p/rootle-gitlab.tar.gz"
));
assert!(!tracks_releases("/opt/providers/rootle-gitlab"));
}
fn test_manager(tag: &str) -> Manager {
let root = std::env::temp_dir().join(format!("rootle-mgr-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
Manager::rooted_at(root.join("store"), root.join("state"))
}
fn tarball_with(binary_name: &str, bytes: &[u8]) -> Vec<u8> {
let enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
let mut builder = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(bytes.len() as u64);
header.set_mode(0o755);
header.set_entry_type(tar::EntryType::Regular);
header.set_cksum();
builder
.append_data(&mut header, format!("pkg/{binary_name}"), bytes)
.unwrap();
let enc = builder.into_inner().unwrap();
enc.finish().unwrap()
}
#[test]
fn plain_http_install_downloads_verifies_and_pins() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let target = super::super::release::platform_target();
let file = format!("rootle-gitlab-0.1.0-{target}.tar.gz");
let tarball = tarball_with("rootle-gitlab", b"#!/bin/sh\necho fake\n");
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let server = rt.block_on(MockServer::start());
rt.block_on(async {
Mock::given(method("GET"))
.and(path(format!("/providers/{file}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(tarball.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/providers/{file}.sha256")))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(format!("{} {file}", sha256_hex(&tarball))),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/providers/tampered-{file}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(tarball.clone()))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path(format!("/providers/tampered-{file}.sha256")))
.respond_with(ResponseTemplate::new(200).set_body_string("deadbeef x"))
.mount(&server)
.await;
});
let manager = test_manager("http-install");
let url = format!("{}/providers/{file}", server.uri());
let r = Ref::parse(&url).unwrap();
assert_eq!(r.tarball.as_deref(), Some(url.as_str()));
let receipt = manager.install(&r, false).expect("verified install");
assert!(receipt.pinned, "plain-HTTP installs are install-and-pin");
assert_eq!(receipt.source, url);
assert_eq!(receipt.tag, "v0.1.0");
assert_eq!(receipt.sha256, sha256_hex(&tarball));
assert_eq!(receipt.latest_tag, None);
let bin = manager.current_binary("gitlab").expect("current resolves");
assert_eq!(std::fs::read(&bin).unwrap(), b"#!/bin/sh\necho fake\n");
let again = manager.install(&r, false).unwrap_err().to_string();
assert!(again.contains("already installed"), "got: {again}");
let bad_url = format!("{}/providers/tampered-{file}", server.uri());
let bad = Ref::parse(&bad_url).unwrap();
let err = manager.install(&bad, false).unwrap_err().to_string();
assert!(err.contains("checksum mismatch"), "got: {err}");
assert!(manager.receipt("tampered-gitlab").is_none());
assert!(manager.update(None).unwrap().is_empty());
manager.upgrade(None, false, true).unwrap();
assert_eq!(manager.receipt("gitlab").unwrap().tag, "v0.1.0");
}
}