1mod error;
4mod manifest;
5mod platform;
6mod skill;
7mod verify;
8
9pub use error::InstallerError;
10pub(crate) use manifest::is_allowed_manifest_url;
11pub(crate) use platform::{binary_candidates, default_local_bin, find_in_path, home_dir};
12
13use manifest::{ReleaseBinary, ReleaseManifest, load_manifest};
14use platform::{
15 check_dir_writable, executable_name, platform_binary_name, set_executable_permissions,
16};
17use skill::{install_skill, remove_skill};
18use verify::{decode_public_key, sha256_hex, verify_signature};
19
20use minisign_verify::PublicKey;
21use std::{
22 env, fs, io,
23 path::{Path, PathBuf},
24 time::Duration,
25};
26use tempfile::TempDir;
27
28const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
29
30pub(super) fn http_client() -> Result<reqwest::blocking::Client, InstallerError> {
32 Ok(reqwest::blocking::Client::builder()
33 .timeout(HTTP_TIMEOUT)
34 .build()?)
35}
36
37pub(super) fn file_url_to_path(url_str: &str) -> Option<PathBuf> {
42 url::Url::parse(url_str)
43 .ok()
44 .filter(|u| u.scheme() == "file")
45 .and_then(|u| u.to_file_path().ok())
46}
47
48#[derive(Debug, Clone)]
50pub(crate) struct InstallSource {
51 pub(crate) manifest: Option<String>,
53 pub(crate) public_key: Option<String>,
55}
56
57#[derive(Debug, Clone)]
59pub(crate) struct Installer {
60 pub(crate) bin_dir: PathBuf,
62}
63
64#[derive(Debug, Clone)]
67pub(crate) struct InstallResult {
68 pub(crate) version: String,
70 pub(crate) description: String,
72}
73
74#[derive(Debug)]
76struct ResolvedInstall {
77 version: String,
79 description: String,
81 src: Option<PathBuf>,
83 dst: PathBuf,
85 skill_url: Option<String>,
87 skill_sha256: Option<String>,
89 skill_signature: Option<String>,
91 public_key: PublicKey,
93 _download_dir: TempDir,
95}
96
97impl Installer {
98 pub(crate) fn from_env(exe_dir: Option<&Path>) -> Result<Self, InstallerError> {
99 let bin_dir = if env::var_os("TEMPO_HOME").is_some() {
100 fallback_bin_dir().expect("TEMPO_HOME is set")
101 } else if let Some(dir) = exe_dir.filter(|d| d.is_dir() && check_dir_writable(d).is_ok()) {
102 dir.to_path_buf()
103 } else {
104 default_local_bin()?
105 };
106
107 Ok(Self { bin_dir })
108 }
109
110 pub(crate) fn install(
112 &self,
113 extension: &str,
114 source: &InstallSource,
115 dry_run: bool,
116 quiet: bool,
117 ) -> Result<InstallResult, InstallerError> {
118 self.install_inner(extension, source, None, dry_run, quiet)
119 }
120
121 pub(crate) fn check_latest_version(
125 source: &InstallSource,
126 installed_version: Option<&str>,
127 ) -> Result<Option<String>, InstallerError> {
128 let manifest_loc = source
129 .manifest
130 .as_ref()
131 .ok_or(InstallerError::MissingReleaseManifest)?;
132 if !is_allowed_manifest_url(manifest_loc) {
133 return Err(InstallerError::InsecureManifestUrl(manifest_loc.clone()));
134 }
135
136 let manifest = load_manifest(manifest_loc)?;
137 if is_newer(&manifest.version, installed_version) {
138 Ok(Some(manifest.version))
139 } else {
140 Ok(None)
141 }
142 }
143
144 pub(crate) fn install_if_changed(
148 &self,
149 extension: &str,
150 source: &InstallSource,
151 installed_version: Option<&str>,
152 ) -> Result<Option<InstallResult>, InstallerError> {
153 let manifest_loc = source
154 .manifest
155 .as_ref()
156 .ok_or(InstallerError::MissingReleaseManifest)?;
157 if !is_allowed_manifest_url(manifest_loc) {
158 return Err(InstallerError::InsecureManifestUrl(manifest_loc.clone()));
159 }
160
161 let manifest = load_manifest(manifest_loc)?;
162 if !is_newer(&manifest.version, installed_version) {
163 return Ok(None);
164 }
165
166 let result = self.install_inner(extension, source, Some(manifest), false, true)?;
167 Ok(Some(result))
168 }
169
170 fn install_inner(
172 &self,
173 extension: &str,
174 source: &InstallSource,
175 manifest: Option<ReleaseManifest>,
176 dry_run: bool,
177 quiet: bool,
178 ) -> Result<InstallResult, InstallerError> {
179 self.ensure_dirs(dry_run)?;
180
181 let resolved = self.resolve_install(extension, source, manifest, dry_run, quiet)?;
182 let result = InstallResult {
183 version: resolved.version.clone(),
184 description: resolved.description.clone(),
185 };
186 self.copy_binary(&resolved, dry_run, quiet)?;
187
188 if let Some(skill_url) = &resolved.skill_url {
189 install_skill(
190 extension,
191 &resolved.version,
192 skill_url,
193 resolved.skill_sha256.as_deref(),
194 resolved.skill_signature.as_deref(),
195 &resolved.public_key,
196 dry_run,
197 quiet,
198 );
199 }
200
201 Ok(result)
202 }
203
204 pub(crate) fn remove(&self, extension: &str, dry_run: bool) -> Result<(), InstallerError> {
206 let binary = format!("tempo-{extension}");
207 self.remove_binary(&binary, dry_run)?;
208 remove_skill(extension, dry_run);
209 Ok(())
210 }
211
212 fn resolve_install(
214 &self,
215 extension: &str,
216 source: &InstallSource,
217 pre_manifest: Option<ReleaseManifest>,
218 dry_run: bool,
219 quiet: bool,
220 ) -> Result<ResolvedInstall, InstallerError> {
221 let binary = format!("tempo-{extension}");
222
223 let manifest_loc = source
224 .manifest
225 .clone()
226 .ok_or(InstallerError::MissingReleaseManifest)?;
227 if !is_allowed_manifest_url(&manifest_loc) {
228 return Err(InstallerError::InsecureManifestUrl(manifest_loc));
229 }
230 let public_key = source
231 .public_key
232 .clone()
233 .ok_or(InstallerError::MissingReleasePublicKey)?;
234
235 let public_key_parsed = decode_public_key(&public_key)?;
236 let manifest = match pre_manifest {
237 Some(m) => m,
238 None => {
239 tracing::debug!("fetching manifest from {manifest_loc}");
240 load_manifest(&manifest_loc)?
241 }
242 };
243 if !quiet {
244 println!("installing {binary} {}", manifest.version);
245 }
246
247 let platform_key = platform_binary_name(extension);
248 tracing::debug!("platform key: {platform_key}");
249 let metadata = manifest
250 .binaries
251 .get(&platform_key)
252 .ok_or_else(|| InstallerError::ExtensionNotInManifest(platform_key.to_string()))?;
253
254 let download_dir = TempDir::new()?;
255 let src = download_extension(
256 &binary,
257 &platform_key,
258 &manifest.version,
259 metadata,
260 &public_key_parsed,
261 download_dir.path(),
262 dry_run,
263 )?;
264 let dst = self.bin_dir.join(executable_name(&binary));
265
266 Ok(ResolvedInstall {
267 version: manifest.version.clone(),
268 description: manifest.description.clone().unwrap_or_default(),
269 src,
270 dst,
271 skill_url: manifest.skill.clone(),
272 skill_sha256: manifest.skill_sha256.clone(),
273 skill_signature: manifest.skill_signature.clone(),
274 public_key: public_key_parsed,
275 _download_dir: download_dir,
276 })
277 }
278
279 fn copy_binary(
281 &self,
282 resolved: &ResolvedInstall,
283 dry_run: bool,
284 quiet: bool,
285 ) -> Result<(), InstallerError> {
286 if dry_run {
287 println!("dry-run: install -> {}", resolved.dst.display());
288 return Ok(());
289 }
290
291 let src = resolved
292 .src
293 .as_ref()
294 .expect("src must exist after download");
295 let dst_dir = resolved
296 .dst
297 .parent()
298 .expect("dst must have a parent directory");
299 let mut tmp = tempfile::Builder::new()
300 .prefix(".tempo-install-")
301 .tempfile_in(dst_dir)?;
302 let mut src_file = fs::File::open(src)?;
305 io::copy(&mut src_file, &mut tmp)?;
306 drop(src_file);
307 set_executable_permissions(tmp.as_file())?;
310 let tmp_path = tmp.into_temp_path();
313 tmp_path.persist(&resolved.dst).map_err(|e| e.error)?;
317 if !quiet {
318 println!("installed {} -> {}", src.display(), resolved.dst.display());
319 }
320
321 Ok(())
322 }
323
324 fn remove_binary(&self, binary: &str, dry_run: bool) -> Result<(), InstallerError> {
326 let path = self.bin_dir.join(executable_name(binary));
327
328 if dry_run {
329 println!("dry-run: remove {}", path.display());
330 } else if path.exists() {
331 fs::remove_file(&path)?;
332 println!("removed {}", path.display());
333 }
334
335 Ok(())
336 }
337
338 fn ensure_dirs(&self, dry_run: bool) -> Result<(), InstallerError> {
340 if dry_run {
341 println!("dry-run: ensure dir {}", self.bin_dir.display());
342 return Ok(());
343 }
344
345 fs::create_dir_all(&self.bin_dir)?;
346 check_dir_writable(&self.bin_dir)?;
347 Ok(())
348 }
349}
350
351pub(crate) fn fallback_bin_dir() -> Option<PathBuf> {
353 if let Some(home) = env::var_os("TEMPO_HOME") {
354 Some(PathBuf::from(home).join("bin"))
355 } else {
356 default_local_bin().ok()
357 }
358}
359
360fn download_extension(
363 binary: &str,
364 platform_key: &str,
365 version: &str,
366 metadata: &ReleaseBinary,
367 public_key: &PublicKey,
368 download_dir: &Path,
369 dry_run: bool,
370) -> Result<Option<PathBuf>, InstallerError> {
371 if dry_run {
372 if metadata.signature.is_none() {
373 return Err(InstallerError::SignatureMissing(binary.to_string()));
374 }
375 println!("dry-run: fetch {binary} from {}", metadata.url);
376 println!("dry-run: verify signature for {binary}");
377 return Ok(None);
378 }
379
380 let dst = download_dir.join(executable_name(binary));
381
382 if metadata.url.starts_with("http://") {
383 return Err(InstallerError::InsecureDownloadUrl(metadata.url.clone()));
384 }
385
386 if metadata.url.starts_with("https://") {
387 let mut response = http_client()?
388 .get(&metadata.url)
389 .send()?
390 .error_for_status()?;
391 let mut file = fs::File::create(&dst)?;
392 io::copy(&mut response, &mut file)?;
393 } else if let Some(path) = file_url_to_path(&metadata.url) {
394 fs::copy(path, &dst)?;
395 } else {
396 return Err(InstallerError::InsecureDownloadUrl(metadata.url.clone()));
397 }
398
399 let bytes = fs::read(&dst)?;
400
401 tracing::debug!("verifying checksum for {binary}");
402 let actual = sha256_hex(&bytes);
403 let expected = metadata.sha256.to_lowercase();
404 if actual != expected {
405 let _ = fs::remove_file(&dst);
406 return Err(InstallerError::ChecksumMismatch {
407 binary: binary.to_string(),
408 expected,
409 actual,
410 });
411 }
412
413 tracing::debug!("checksum ok for {binary}");
414
415 let encoded_signature = metadata
416 .signature
417 .as_deref()
418 .ok_or_else(|| InstallerError::SignatureMissing(binary.to_string()))?;
419 tracing::debug!("verifying signature for {binary}");
420 let file_comment = format!("file:{platform_key}");
421 let version_comment = format!("version:{version}");
422 if let Err(err) = verify_signature(
423 binary,
424 &bytes,
425 encoded_signature,
426 public_key,
427 &[&file_comment, &version_comment],
428 ) {
429 let _ = fs::remove_file(&dst);
430 return Err(err);
431 }
432
433 tracing::debug!("signature ok for {binary}");
434
435 Ok(Some(dst))
436}
437
438fn is_newer(manifest_version: &str, installed_version: Option<&str>) -> bool {
443 let Some(installed) = installed_version else {
444 return true;
445 };
446 if let (Ok(installed_v), Ok(manifest_v)) = (
447 semver::Version::parse(installed.strip_prefix('v').unwrap_or(installed)),
448 semver::Version::parse(
449 manifest_version
450 .strip_prefix('v')
451 .unwrap_or(manifest_version),
452 ),
453 ) {
454 manifest_v > installed_v
455 } else {
456 installed != manifest_version
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::file_url_to_path;
464 use std::path::Path;
465
466 #[test]
467 fn file_url_unix_absolute() {
468 let path = file_url_to_path("file:///tmp/manifest.json").unwrap();
469 assert_eq!(path, Path::new("/tmp/manifest.json"));
470 }
471
472 #[test]
473 fn file_url_with_spaces() {
474 let path = file_url_to_path("file:///tmp/my%20dir/manifest.json").unwrap();
475 assert_eq!(path, Path::new("/tmp/my dir/manifest.json"));
476 }
477
478 #[test]
479 fn https_url_returns_none() {
480 assert!(file_url_to_path("https://example.com/manifest.json").is_none());
481 }
482
483 #[test]
484 fn bare_path_returns_none() {
485 assert!(file_url_to_path("/tmp/manifest.json").is_none());
486 }
487
488 #[test]
489 fn relative_path_returns_none() {
490 assert!(file_url_to_path("./manifest.json").is_none());
491 }
492
493 #[test]
498 fn is_newer_no_installed_version() {
499 assert!(super::is_newer("1.0.0", None));
500 }
501
502 #[test]
503 fn is_newer_semver_upgrade() {
504 assert!(super::is_newer("2.0.0", Some("1.0.0")));
505 }
506
507 #[test]
508 fn is_newer_semver_same() {
509 assert!(!super::is_newer("1.0.0", Some("1.0.0")));
510 }
511
512 #[test]
513 fn is_newer_semver_downgrade() {
514 assert!(!super::is_newer("1.0.0", Some("2.0.0")));
515 }
516
517 #[test]
518 fn is_newer_strips_v_prefix() {
519 assert!(!super::is_newer("1.0.0", Some("v1.0.0")));
520 assert!(!super::is_newer("v1.0.0", Some("1.0.0")));
521 assert!(super::is_newer("v2.0.0", Some("v1.0.0")));
522 }
523
524 #[test]
525 fn is_newer_non_semver_same() {
526 assert!(!super::is_newer(
527 "nightly-2025-01-01",
528 Some("nightly-2025-01-01")
529 ));
530 }
531
532 #[test]
533 fn is_newer_non_semver_different() {
534 assert!(super::is_newer(
535 "nightly-2025-03-09",
536 Some("nightly-2025-01-01")
537 ));
538 }
539}