diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 22:51:28 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 22:51:28 +0300 |
| commit | eac2314e6ccb4ba91b7272523e68110035167279 (patch) | |
| tree | cfa813cec6164a75366dfeeaa88d97ab9fa6e0fa /src | |
| parent | 427c2606d8f62558e3949d2f44173175e96bed49 (diff) | |
fix: handle package-manager remote updates
Diffstat (limited to 'src')
| -rw-r--r-- | src/remote.rs | 245 | ||||
| -rw-r--r-- | src/update.rs | 175 |
2 files changed, 385 insertions, 35 deletions
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<u32>, + assets: BTreeMap<String, String>, + #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] + releases: BTreeMap<String, RemoteReleaseMetadata>, +} + +#[derive(Deserialize)] +struct RemoteReleaseMetadata { + protocol: Option<u32>, + #[serde(default)] assets: BTreeMap<String, String>, } +fn deserialize_remote_manifest_releases<'de, D>( + deserializer: D, +) -> Result<BTreeMap<String, RemoteReleaseMetadata>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::<serde_json::Value>::deserialize(deserializer)?; + Ok(match value { + Some(serde_json::Value::Object(object)) => object + .into_iter() + .filter_map(|(version, release)| { + serde_json::from_value::<RemoteReleaseMetadata>(release) + .ok() + .map(|metadata| (version, metadata)) + }) + .collect(), + _ => BTreeMap::new(), + }) +} + +impl RemoteUpdateManifest { + fn release_for_version(&self, version: &str) -> Option<RemoteManifestReleaseRef<'_>> { + 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<u32>, + assets: &'a BTreeMap<String, String>, +} + struct InstallSource { path: PathBuf, temporary_dir: Option<PathBuf>, @@ -525,11 +579,23 @@ fn remote_binary_override_path() -> io::Result<Option<PathBuf>> { } 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<InstallSource let manifest: RemoteUpdateManifest = serde_json::from_slice(&manifest_output.stdout) .map_err(|err| io::Error::other(format!("failed to parse update manifest JSON: {err}")))?; - if manifest.version.trim_start_matches('v') != CURRENT_VERSION { - return Err(io::Error::other(format!( - "remote host is {}, but this local herdr is {CURRENT_VERSION} and the latest release manifest is {}; build herdr for the remote platform or install it there manually", - platform.asset_key(), - manifest.version - ))); - } let asset_key = platform.asset_key(); - let url = manifest.assets.get(&asset_key).ok_or_else(|| { + let release = manifest.release_for_version(CURRENT_VERSION).ok_or_else(|| { + io::Error::other(format!( + "release manifest does not include herdr {CURRENT_VERSION}; build herdr for {} or install it there manually", + platform.asset_key() + )) + })?; + if let Some(protocol) = release.protocol { + if protocol != CURRENT_PROTOCOL { + return Err(io::Error::other(format!( + "release manifest has herdr {CURRENT_VERSION} protocol {protocol}, but this client needs protocol {CURRENT_PROTOCOL}; set {REMOTE_BINARY_ENV_VAR}=target/release/herdr or install a matching herdr on the remote host manually" + ))); + } + } + let url = release.assets.get(&asset_key).ok_or_else(|| { io::Error::other(format!( "no {asset_key} binary in the release manifest for herdr {CURRENT_VERSION}" )) @@ -1805,6 +1889,122 @@ mod tests { } #[test] + fn remote_update_manifest_uses_root_assets_for_latest_version() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.3", + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.assets.get("linux-x86_64")) + .map(String::as_str), + Some("https://example.com/latest") + ); + } + + #[test] + fn remote_update_manifest_reads_archived_release_assets() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.assets.get("linux-x86_64")) + .map(String::as_str), + Some("https://example.com/archive") + ); + } + + #[test] + fn remote_update_manifest_uses_archived_release_protocol() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "protocol": 42, + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "protocol": 41, + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.protocol), + Some(41) + ); + } + + #[test] + fn remote_update_manifest_does_not_inherit_latest_protocol_for_archived_assets() { + let manifest: RemoteUpdateManifest = serde_json::from_str( + r#"{ + "version": "1.2.4", + "protocol": 42, + "assets": { + "linux-x86_64": "https://example.com/latest" + }, + "releases": { + "1.2.3": { + "notes": "ignored", + "assets": { + "linux-x86_64": "https://example.com/archive" + } + } + } + }"#, + ) + .unwrap(); + + assert_eq!( + manifest + .release_for_version("1.2.3") + .and_then(|release| release.protocol), + None + ); + } + + #[test] fn remote_server_restart_reason_requires_stop_for_protocol_mismatch() { assert_eq!( remote_server_restart_reason(Some(CURRENT_VERSION), Some(0), false), @@ -1847,12 +2047,35 @@ mod tests { arch: "aarch64", }; assert_eq!( - install_source_description(&platform, Some(Path::new("/tmp/herdr-aarch64"))), + install_source_description_for(&platform, Some(Path::new("/tmp/herdr-aarch64")), false), "HERDR_REMOTE_BINARY (/tmp/herdr-aarch64)" ); } #[test] + fn install_source_description_uses_local_binary_when_allowed() { + let platform = RemotePlatform::local(); + + assert_eq!( + install_source_description_for(&platform, None, true), + "the current local herdr binary" + ); + } + + #[test] + fn install_source_description_uses_release_asset_when_local_binary_cannot_seed_remote() { + let platform = RemotePlatform::local(); + + assert_eq!( + install_source_description_for(&platform, None, false), + format!( + "the {CURRENT_VERSION} release asset for {}", + platform.asset_key() + ) + ); + } + + #[test] fn resolve_install_source_uses_override_binary_without_temporary_cleanup() { let platform = RemotePlatform { os: "linux", diff --git a/src/update.rs b/src/update.rs index afbbc662..81cfb848 100644 --- a/src/update.rs +++ b/src/update.rs @@ -276,6 +276,18 @@ fn parse_homebrew_formula_stable_version(input: &[u8]) -> Result<Version, String }) } +fn homebrew_update_from_formula_json( + input: &[u8], + current: &Version, +) -> Result<Option<Version>, String> { + let latest = parse_homebrew_formula_stable_version(input)?; + if &latest <= current { + return Ok(None); + } + + Ok(Some(latest)) +} + fn check_homebrew_latest() -> Result<Option<Version>, String> { let current = Version::current(); @@ -294,15 +306,10 @@ fn check_homebrew_latest() -> Result<Option<Version>, 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<crate::events::AppEven } fn homebrew_release_notes_body(version: &Version) -> 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; } } @@ -2102,10 +2126,34 @@ mod tests { } #[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] @@ -2126,6 +2174,59 @@ mod tests { } #[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(); std::env::remove_var(FAKE_UPDATE_NOTES_VERSION_ENV); @@ -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}" + ); + } + } } } |
