From eac2314e6ccb4ba91b7272523e68110035167279 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 26 May 2026 22:51:28 +0300 Subject: fix: handle package-manager remote updates --- src/remote.rs | 245 +++++++++++++++++++++++++++++++++++++++++++++++++++++++--- src/update.rs | 175 +++++++++++++++++++++++++++++++++++------ 2 files changed, 385 insertions(+), 35 deletions(-) (limited to 'src') diff --git a/src/remote.rs b/src/remote.rs index 9a4cc774..3cf1e43b 100644 --- a/src/remote.rs +++ b/src/remote.rs @@ -300,9 +300,63 @@ impl RemoteHerdr { #[derive(Deserialize)] struct RemoteUpdateManifest { version: String, + protocol: Option, + assets: BTreeMap, + #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] + releases: BTreeMap, +} + +#[derive(Deserialize)] +struct RemoteReleaseMetadata { + protocol: Option, + #[serde(default)] assets: BTreeMap, } +fn deserialize_remote_manifest_releases<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::Object(object)) => object + .into_iter() + .filter_map(|(version, release)| { + serde_json::from_value::(release) + .ok() + .map(|metadata| (version, metadata)) + }) + .collect(), + _ => BTreeMap::new(), + }) +} + +impl RemoteUpdateManifest { + fn release_for_version(&self, version: &str) -> Option> { + if self.version.trim_start_matches('v') == version { + return Some(RemoteManifestReleaseRef { + protocol: self.protocol, + assets: &self.assets, + }); + } + + self.releases.get(version).and_then(|release| { + (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { + protocol: release.protocol, + assets: &release.assets, + }) + }) + } +} + +#[derive(Clone, Copy)] +struct RemoteManifestReleaseRef<'a> { + protocol: Option, + assets: &'a BTreeMap, +} + struct InstallSource { path: PathBuf, temporary_dir: Option, @@ -525,11 +579,23 @@ fn remote_binary_override_path() -> io::Result> { } fn install_source_description(platform: &RemotePlatform, override_binary: Option<&Path>) -> String { + install_source_description_for( + platform, + override_binary, + local_binary_can_seed_remote(platform), + ) +} + +fn install_source_description_for( + platform: &RemotePlatform, + override_binary: Option<&Path>, + local_binary_can_seed_remote: bool, +) -> String { if let Some(path) = override_binary { return format!("{REMOTE_BINARY_ENV_VAR} ({})", path.display()); } - if *platform == RemotePlatform::local() { + if local_binary_can_seed_remote { "the current local herdr binary".to_string() } else { format!( @@ -549,12 +615,24 @@ fn resolve_install_source( if *platform == RemotePlatform::local() { let path = std::env::current_exe()?; - return Ok(InstallSource::persistent(path)); + if !crate::update::is_package_manager_managed_exe_path(&path) { + return Ok(InstallSource::persistent(path)); + } } download_release_asset(platform) } +fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool { + if *platform != RemotePlatform::local() { + return false; + } + + std::env::current_exe() + .map(|path| !crate::update::is_package_manager_managed_exe_path(&path)) + .unwrap_or(false) +} + #[derive(Debug, Clone, PartialEq, Eq)] enum RemoteServerStatus { Running { @@ -985,16 +1063,22 @@ fn download_release_asset(platform: &RemotePlatform) -> io::Result Result Result, String> { + let latest = parse_homebrew_formula_stable_version(input)?; + if &latest <= current { + return Ok(None); + } + + Ok(Some(latest)) +} + fn check_homebrew_latest() -> Result, String> { let current = Version::current(); @@ -294,15 +306,10 @@ fn check_homebrew_latest() -> Result, String> { .map_err(|e| format!("curl failed: {e}"))?; if !output.status.success() { - return Ok(None); - } - - let latest = parse_homebrew_formula_stable_version(&output.stdout)?; - if latest <= current { - return Ok(None); + return Err("failed to fetch Homebrew formula JSON".into()); } - Ok(Some(latest)) + homebrew_update_from_formula_json(&output.stdout, ¤t) } // --------------------------------------------------------------------------- @@ -1425,13 +1432,7 @@ fn is_homebrew_managed_install() -> bool { return false; }; - if is_homebrew_managed_exe_path(¤t_exe) { - return true; - } - - current_exe - .canonicalize() - .is_ok_and(|path| is_homebrew_managed_exe_path(&path)) + is_homebrew_managed_exe_path_following_links(¤t_exe) } fn is_nix_managed_install() -> bool { @@ -1439,12 +1440,29 @@ fn is_nix_managed_install() -> bool { return false; }; - if is_nix_store_exe_path(¤t_exe) { + is_nix_store_exe_path_following_links(¤t_exe) +} + +pub(crate) fn is_package_manager_managed_exe_path(path: &Path) -> bool { + is_homebrew_managed_exe_path_following_links(path) + || is_nix_store_exe_path_following_links(path) +} + +fn is_homebrew_managed_exe_path_following_links(path: &Path) -> bool { + if is_homebrew_managed_exe_path(path) { + return true; + } + + path.canonicalize() + .is_ok_and(|path| is_homebrew_managed_exe_path(&path)) +} + +fn is_nix_store_exe_path_following_links(path: &Path) -> bool { + if is_nix_store_exe_path(path) { return true; } - current_exe - .canonicalize() + path.canonicalize() .is_ok_and(|path| is_nix_store_exe_path(&path)) } @@ -1903,13 +1921,19 @@ fn auto_update_homebrew(events: tokio::sync::mpsc::Sender String { - if let Ok(manifest) = fetch_update_manifest() { - if let Some(metadata) = manifest.metadata_for_version(version) { - let notes_body = metadata.notes_body(); - if !notes_body.is_empty() { - handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref()); - return notes_body; - } + let manifest = fetch_update_manifest().ok(); + homebrew_release_notes_body_from_manifest(version, manifest.as_ref()) +} + +fn homebrew_release_notes_body_from_manifest( + version: &Version, + manifest: Option<&UpdateManifest>, +) -> String { + if let Some(metadata) = manifest.and_then(|manifest| manifest.metadata_for_version(version)) { + let notes_body = metadata.notes_body(); + if !notes_body.is_empty() { + handle_manifest_announcement(&version.to_string(), metadata.announcement.as_ref()); + return notes_body; } } @@ -2101,11 +2125,35 @@ mod tests { assert!(!is_homebrew_managed_exe_path(path)); } + #[test] + fn package_manager_path_detection_follows_homebrew_symlink() { + #[cfg(unix)] + { + let root = std::env::temp_dir().join(format!( + "herdr-homebrew-symlink-test-{}", + std::process::id() + )); + let cellar_bin = root.join("Cellar/herdr/0.6.2/bin"); + let opt_bin = root.join("opt/herdr/bin"); + fs::create_dir_all(&cellar_bin).unwrap(); + fs::create_dir_all(&opt_bin).unwrap(); + let cellar_binary = cellar_bin.join("herdr"); + let opt_binary = opt_bin.join("herdr"); + fs::write(&cellar_binary, b"").unwrap(); + std::os::unix::fs::symlink(&cellar_binary, &opt_binary).unwrap(); + + assert!(is_package_manager_managed_exe_path(&opt_binary)); + + let _ = fs::remove_dir_all(root); + } + } + #[test] fn nix_store_path_is_detected() { let path = Path::new("/nix/store/abc123-herdr-0.6.1/bin/herdr"); assert!(is_nix_store_exe_path(path)); + assert!(is_package_manager_managed_exe_path(path)); } #[test] @@ -2125,6 +2173,59 @@ mod tests { assert_eq!(version, Version::parse("0.5.10").unwrap()); } + #[test] + fn homebrew_formula_update_uses_formula_stable_not_manifest_latest() { + let current = Version::parse("0.6.1").unwrap(); + let update = homebrew_update_from_formula_json( + br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#, + ¤t, + ) + .unwrap(); + + assert_eq!(update, Some(Version::parse("0.6.2").unwrap())); + } + + #[test] + fn homebrew_formula_update_ignores_versions_that_are_not_newer() { + let current = Version::parse("0.6.2").unwrap(); + let update = homebrew_update_from_formula_json( + br#"{"versions":{"stable":"0.6.2","head":"HEAD","bottle":true}}"#, + ¤t, + ) + .unwrap(); + + assert_eq!(update, None); + } + + #[test] + fn homebrew_release_notes_use_package_manager_guidance() { + let body = + homebrew_release_notes_body_from_manifest(&Version::parse("0.6.3").unwrap(), None); + + assert_eq!(body, "### Changed\n- v0.6.3 is available through Homebrew."); + } + + #[test] + fn homebrew_release_notes_can_use_manifest_metadata() { + let manifest: UpdateManifest = serde_json::from_str( + r####"{ + "version": "0.6.3", + "protocol": 10, + "notes": "### Fixed\n- Brew notes", + "assets": { + "linux-x86_64": "https://example.com/herdr-linux-x86_64" + } + }"####, + ) + .unwrap(); + let body = homebrew_release_notes_body_from_manifest( + &Version::parse("0.6.3").unwrap(), + Some(&manifest), + ); + + assert_eq!(body, "### Fixed\n- Brew notes"); + } + #[test] fn fake_release_notes_default_to_real_large_changelog_section() { let _guard = env_lock().lock().unwrap(); @@ -2954,5 +3055,31 @@ mod tests { "unexpected asset name for {target}: {url}" ); } + + for (version, release) in &manifest.releases { + let assets = release + .get("assets") + .and_then(serde_json::Value::as_object) + .unwrap_or_else(|| panic!("missing assets for release {version}")); + for target in [ + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", + ] { + let url = assets + .get(target) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("missing asset URL for {version} {target}")); + assert!( + url.contains(&format!("/releases/download/v{version}/")), + "unexpected release URL for {version} {target}: {url}" + ); + assert!( + url.ends_with(&format!("herdr-{target}")), + "unexpected asset name for {version} {target}: {url}" + ); + } + } } } -- cgit v1.2.3