From 5f3763dda88e0fefbce14afab7cc2c7232a5e2a8 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Wed, 16 Sep 2026 23:55:12 +0300 Subject: fix: cache saved machine connection metadata (#4256) refs #4252 --- src/client/endpoint.rs | 2 + src/client/endpoint/catalog.rs | 6 +- src/client/endpoint/ssh_metadata.rs | 221 ++++++++++++++++++++++++++++++++++++ 3 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 src/client/endpoint/ssh_metadata.rs (limited to 'src/client') diff --git a/src/client/endpoint.rs b/src/client/endpoint.rs index 77aaae3b..f6ae4109 100644 --- a/src/client/endpoint.rs +++ b/src/client/endpoint.rs @@ -9,6 +9,7 @@ mod control; mod health; mod message_policy; mod registry; +mod ssh_metadata; mod supervisor; mod writer; @@ -17,6 +18,7 @@ pub(crate) use catalog::*; pub(crate) use control::*; pub(crate) use message_policy::*; pub(crate) use registry::*; +pub(crate) use ssh_metadata::{SshMachineMetadata, SshMetadataCache}; pub(crate) use supervisor::*; pub(crate) use writer::NativeEndpointTransport; diff --git a/src/client/endpoint/catalog.rs b/src/client/endpoint/catalog.rs index 23f26387..e65d1e9e 100644 --- a/src/client/endpoint/catalog.rs +++ b/src/client/endpoint/catalog.rs @@ -331,7 +331,11 @@ fn load_selection_from_path(path: &Path) -> Result, St Ok(Some(selection)) } -fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> { +pub(super) fn store_private_json( + path: &Path, + content: &[u8], + description: &str, +) -> Result<(), String> { if content.len() as u64 > MAX_CATALOG_BYTES { return Err(format!("{description} exceeds the storage limit")); } diff --git a/src/client/endpoint/ssh_metadata.rs b/src/client/endpoint/ssh_metadata.rs new file mode 100644 index 00000000..bc8dffd6 --- /dev/null +++ b/src/client/endpoint/ssh_metadata.rs @@ -0,0 +1,221 @@ +use std::io::{self, Read as _}; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::ProfileId; + +const MAX_METADATA_BYTES: u64 = 16 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SshMachineMetadata { + pub(crate) os: String, + pub(crate) executable: String, +} + +impl SshMachineMetadata { + pub(crate) fn is_valid(&self) -> bool { + let path = &self.executable; + if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) { + return false; + } + match self.os.as_str() { + "linux" | "macos" => path.starts_with('/') && !path.ends_with("/mise/shims/herdr"), + "windows" => { + let bytes = path.as_bytes(); + path.starts_with(r"\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/')) + } + _ => false, + } + } +} + +#[derive(Serialize, Deserialize)] +struct StoredMetadata { + version: u32, + target: String, + session: String, + metadata: SshMachineMetadata, +} + +pub(crate) struct SshMetadataCache { + path: PathBuf, + target: String, + session: String, +} + +impl SshMetadataCache { + pub(crate) fn new(profile_id: &str, target: &str, session: &str) -> io::Result { + let id = ProfileId::parse(profile_id) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + Ok(Self { + path: crate::config::state_dir() + .join("client/ssh-metadata") + .join(format!("{id}.json")), + target: target.to_owned(), + session: session.to_owned(), + }) + } + + pub(crate) fn load(&self) -> Option { + load_metadata(&self.path, &self.target, &self.session) + } + + pub(crate) fn store(&self, metadata: &SshMachineMetadata) { + if !metadata.is_valid() { + return; + } + let stored = StoredMetadata { + version: 1, + target: self.target.clone(), + session: self.session.clone(), + metadata: metadata.clone(), + }; + let result = serde_json::to_vec(&stored) + .map_err(|error| error.to_string()) + .and_then(|bytes| { + super::catalog::store_private_json(&self.path, &bytes, "SSH metadata") + }); + if let Err(error) = result { + tracing::debug!(%error, "could not cache SSH machine metadata"); + } + } + + pub(crate) fn invalidate(&self) { + if let Err(error) = std::fs::remove_file(&self.path) { + if error.kind() != io::ErrorKind::NotFound { + tracing::debug!(%error, "could not invalidate SSH machine metadata"); + } + } + } +} + +fn load_metadata(path: &Path, target: &str, session: &str) -> Option { + let file_type = std::fs::symlink_metadata(path).ok()?.file_type(); + if !file_type.is_file() { + return None; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .ok()? + .take(MAX_METADATA_BYTES + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() as u64 > MAX_METADATA_BYTES { + return None; + } + let stored: StoredMetadata = serde_json::from_slice(&bytes).ok()?; + (stored.version == 1 + && stored.target == target + && stored.session == session + && stored.metadata.is_valid()) + .then_some(stored.metadata) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_accepts_only_supported_platforms_and_absolute_paths() { + for (os, path, valid) in [ + ("linux", "/home/a b/herdr", true), + ("macos", "/opt/homebrew/bin/herdr", true), + ("windows", r"C:\Program Files\herdr.exe", true), + ("windows", r"\\server\share\herdr.exe", true), + ("windows", "herdr.exe", false), + ("linux", "$HOME/.local/bin/herdr", false), + ("linux", "/home/user/.local/share/mise/shims/herdr", false), + ("linux", "/bin/herdr\nmalformed", false), + ("unknown", "/bin/herdr", false), + ] { + assert_eq!( + SshMachineMetadata { + os: os.into(), + executable: path.into() + } + .is_valid(), + valid, + "{os}: {path}" + ); + } + } + + #[test] + fn metadata_is_disposable_fingerprinted_and_independent_per_profile() { + let root = std::env::temp_dir().join(format!("herdr-ssh-metadata-{}", std::process::id())); + let first = SshMetadataCache { + path: root.join("first.json"), + target: "mac".into(), + session: "fleet".into(), + }; + let second = SshMetadataCache { + path: root.join("second.json"), + target: "mac".into(), + session: "fleet".into(), + }; + let metadata = SshMachineMetadata { + os: "macos".into(), + executable: "/some path/herdr".into(), + }; + assert!(first.load().is_none()); + first.store(&metadata); + second.store(&metadata); + assert_eq!(first.load(), Some(metadata.clone())); + assert!(load_metadata(&first.path, "different-host", "fleet").is_none()); + assert!(load_metadata(&first.path, "mac", "different-session").is_none()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(&first.path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + let mut stored: serde_json::Value = + serde_json::from_slice(&std::fs::read(&first.path).unwrap()).unwrap(); + stored["future_field"] = true.into(); + std::fs::write(&first.path, serde_json::to_vec(&stored).unwrap()).unwrap(); + assert_eq!(first.load(), Some(metadata.clone())); + stored["version"] = 2.into(); + std::fs::write(&first.path, serde_json::to_vec(&stored).unwrap()).unwrap(); + assert!(first.load().is_none()); + for bytes in [ + b"broken".to_vec(), + vec![b' '; MAX_METADATA_BYTES as usize + 1], + ] { + std::fs::write(&first.path, bytes).unwrap(); + assert!(first.load().is_none()); + } + first.invalidate(); + assert_eq!(second.load(), Some(metadata)); + std::fs::remove_dir_all(root).unwrap(); + } + + #[cfg(unix)] + #[test] + fn metadata_does_not_follow_symlinks() { + let root = + std::env::temp_dir().join(format!("herdr-ssh-metadata-link-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let cache = SshMetadataCache { + path: root.join("cache.json"), + target: "mac".into(), + session: "fleet".into(), + }; + let other = root.join("other"); + std::fs::write(&other, "untouched").unwrap(); + std::os::unix::fs::symlink(&other, &cache.path).unwrap(); + assert!(cache.load().is_none()); + cache.store(&SshMachineMetadata { + os: "macos".into(), + executable: "/bin/herdr".into(), + }); + assert_eq!(std::fs::read_to_string(&other).unwrap(), "untouched"); + std::fs::remove_dir_all(root).unwrap(); + } +} -- cgit v1.2.3