Skip to main content

tempo_ext/installer/
mod.rs

1//! Extension lifecycle management: install, update, and remove extensions.
2
3mod 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
30/// Builds an HTTP client with a 30-second timeout for manifest/binary fetches.
31pub(super) fn http_client() -> Result<reqwest::blocking::Client, InstallerError> {
32    Ok(reqwest::blocking::Client::builder()
33        .timeout(HTTP_TIMEOUT)
34        .build()?)
35}
36
37/// Parses a `file://` URL into a local path using the `url` crate.
38///
39/// Returns `None` if the URL isn't a valid `file://` URL or can't be
40/// converted to a platform path (e.g. `file://remote/share` on Windows).
41pub(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/// Where to fetch and verify an extension release from.
49#[derive(Debug, Clone)]
50pub(crate) struct InstallSource {
51    /// URL (or `file://` path) of the signed release manifest JSON.
52    pub(crate) manifest: Option<String>,
53    /// Base64-encoded minisign public key for signature verification.
54    pub(crate) public_key: Option<String>,
55}
56
57/// Handles downloading, verifying, and placing extension binaries.
58#[derive(Debug, Clone)]
59pub(crate) struct Installer {
60    /// Directory where extension binaries are installed.
61    pub(crate) bin_dir: PathBuf,
62}
63
64/// Returned after a successful install with the version and description
65/// from the release manifest.
66#[derive(Debug, Clone)]
67pub(crate) struct InstallResult {
68    /// Installed version string (e.g. `"1.0.0"`).
69    pub(crate) version: String,
70    /// Short description from the release manifest.
71    pub(crate) description: String,
72}
73
74/// Fully resolved install plan: all paths and verification material ready.
75#[derive(Debug)]
76struct ResolvedInstall {
77    /// Version from the release manifest.
78    version: String,
79    /// Short description from the release manifest.
80    description: String,
81    /// Path to the downloaded binary. `None` in dry-run mode.
82    src: Option<PathBuf>,
83    /// Final destination path in `bin_dir`.
84    dst: PathBuf,
85    /// Optional URL for the extension's Claude Code skill file.
86    skill_url: Option<String>,
87    /// Expected SHA-256 hex digest of the skill file.
88    skill_sha256: Option<String>,
89    /// Base64-encoded minisign signature of the skill file.
90    skill_signature: Option<String>,
91    /// Decoded public key used for signature verification.
92    public_key: PublicKey,
93    /// Temp directory holding the download; kept alive until install completes.
94    _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    /// Installs an extension and returns the installed version and description.
111    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    /// Checks if a newer version is available without installing.
122    /// Returns `Some(latest_version)` if the manifest version is strictly
123    /// newer, `None` if already up to date.
124    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    /// Installs an extension only if the manifest version is newer than
145    /// `installed_version`. Returns `Some(result)` if an update was
146    /// performed, `None` if already at the latest version.
147    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    /// Shared implementation for `install` and `install_if_changed`.
171    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    /// Removes an extension's binary and skill files.
205    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    /// Fetches the manifest, downloads the binary, and verifies checksums/signatures.
213    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    /// Atomically places the downloaded binary at its destination path.
280    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        // Write through the open handle to avoid sharing violations on
303        // Windows (fs::copy would try to re-open the file for writing).
304        let mut src_file = fs::File::open(src)?;
305        io::copy(&mut src_file, &mut tmp)?;
306        drop(src_file);
307        // Set permissions via the open handle before closing to avoid
308        // TOCTOU between close and chmod-by-path.
309        set_executable_permissions(tmp.as_file())?;
310        // Close the file handle; TempPath auto-cleans on drop if
311        // persist() is never reached.
312        let tmp_path = tmp.into_temp_path();
313        // persist() uses atomic rename on Unix and MoveFileEx with
314        // MOVEFILE_REPLACE_EXISTING on Windows — handles overwrite on
315        // all platforms.
316        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    /// Deletes the named binary from `bin_dir`.
325    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    /// Creates `bin_dir` if it doesn't exist and verifies it is writable.
339    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
351/// The fallback install directory: `TEMPO_HOME/bin` if set, else `~/.local/bin`.
352pub(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
360/// Downloads an extension binary, verifies its checksum and signature, and
361/// returns the path to the verified file in `download_dir`.
362fn 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
438/// Returns `true` if `manifest_version` is strictly newer than
439/// `installed_version`. Uses semver comparison when both parse as
440/// semver (with optional `v` prefix). For non-semver strings, returns
441/// `true` unless they are identical.
442fn 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        // Non-semver fallback: only skip if identical.
457        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    // NOTE: download_extension's URL scheme enforcement (rejecting http:// and
494    // unknown schemes) requires a PublicKey and real file I/O, so it is
495    // covered by integration tests rather than unit tests here.
496
497    #[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}